blob: e22ea6afbca34e42f378f9ce8f313f4ba92d9dd3 [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'))
dpranke@chromium.orgfe79c312011-04-01 20:15:52 +000030 import simplejson as json # pylint: disable=F0401
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
maruel@chromium.org90541732011-04-01 17:54:18 +000046
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000047def DieWithError(message):
dpranke@chromium.org970c5222011-03-12 00:32:24 +000048 print >> sys.stderr, message
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000049 sys.exit(1)
50
51
52def Popen(cmd, **kwargs):
53 """Wrapper for subprocess.Popen() that logs and watch for cygwin issues"""
54 logging.info('Popen: ' + ' '.join(cmd))
55 try:
56 return subprocess.Popen(cmd, **kwargs)
57 except OSError, e:
58 if e.errno == errno.EAGAIN and sys.platform == 'cygwin':
59 DieWithError(
60 'Visit '
61 'http://code.google.com/p/chromium/wiki/CygwinDllRemappingFailure to '
62 'learn how to fix this error; you need to rebase your cygwin dlls')
63 raise
64
65
66def RunCommand(cmd, error_ok=False, error_message=None,
maruel@chromium.orgb92e4802011-03-03 20:22:00 +000067 redirect_stdout=True, swallow_stderr=False, **kwargs):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000068 if redirect_stdout:
69 stdout = subprocess.PIPE
70 else:
71 stdout = None
72 if swallow_stderr:
73 stderr = subprocess.PIPE
74 else:
75 stderr = None
maruel@chromium.orgb92e4802011-03-03 20:22:00 +000076 proc = Popen(cmd, stdout=stdout, stderr=stderr, **kwargs)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000077 output = proc.communicate()[0]
78 if not error_ok and proc.returncode != 0:
79 DieWithError('Command "%s" failed.\n' % (' '.join(cmd)) +
80 (error_message or output or ''))
81 return output
82
83
84def RunGit(args, **kwargs):
85 cmd = ['git'] + args
86 return RunCommand(cmd, **kwargs)
87
88
89def RunGitWithCode(args):
90 proc = Popen(['git'] + args, stdout=subprocess.PIPE)
91 output = proc.communicate()[0]
92 return proc.returncode, output
93
94
95def usage(more):
96 def hook(fn):
97 fn.usage_more = more
98 return fn
99 return hook
100
101
maruel@chromium.org90541732011-04-01 17:54:18 +0000102def ask_for_data(prompt):
103 try:
104 return raw_input(prompt)
105 except KeyboardInterrupt:
106 # Hide the exception.
107 sys.exit(1)
108
109
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000110def FixUrl(server):
111 """Fix a server url to defaults protocol to http:// if none is specified."""
112 if not server:
113 return server
114 if not re.match(r'[a-z]+\://.*', server):
115 return 'http://' + server
116 return server
117
118
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000119def MatchSvnGlob(url, base_url, glob_spec, allow_wildcards):
120 """Return the corresponding git ref if |base_url| together with |glob_spec|
121 matches the full |url|.
122
123 If |allow_wildcards| is true, |glob_spec| can contain wildcards (see below).
124 """
125 fetch_suburl, as_ref = glob_spec.split(':')
126 if allow_wildcards:
127 glob_match = re.match('(.+/)?(\*|{[^/]*})(/.+)?', fetch_suburl)
128 if glob_match:
129 # Parse specs like "branches/*/src:refs/remotes/svn/*" or
130 # "branches/{472,597,648}/src:refs/remotes/svn/*".
131 branch_re = re.escape(base_url)
132 if glob_match.group(1):
133 branch_re += '/' + re.escape(glob_match.group(1))
134 wildcard = glob_match.group(2)
135 if wildcard == '*':
136 branch_re += '([^/]*)'
137 else:
138 # Escape and replace surrounding braces with parentheses and commas
139 # with pipe symbols.
140 wildcard = re.escape(wildcard)
141 wildcard = re.sub('^\\\\{', '(', wildcard)
142 wildcard = re.sub('\\\\,', '|', wildcard)
143 wildcard = re.sub('\\\\}$', ')', wildcard)
144 branch_re += wildcard
145 if glob_match.group(3):
146 branch_re += re.escape(glob_match.group(3))
147 match = re.match(branch_re, url)
148 if match:
149 return re.sub('\*$', match.group(1), as_ref)
150
151 # Parse specs like "trunk/src:refs/remotes/origin/trunk".
152 if fetch_suburl:
153 full_url = base_url + '/' + fetch_suburl
154 else:
155 full_url = base_url
156 if full_url == url:
157 return as_ref
158 return None
159
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000160class Settings(object):
161 def __init__(self):
162 self.default_server = None
163 self.cc = None
164 self.root = None
165 self.is_git_svn = None
166 self.svn_branch = None
167 self.tree_status_url = None
168 self.viewvc_url = None
169 self.updated = False
170
171 def LazyUpdateIfNeeded(self):
172 """Updates the settings from a codereview.settings file, if available."""
173 if not self.updated:
174 cr_settings_file = FindCodereviewSettingsFile()
175 if cr_settings_file:
176 LoadCodereviewSettingsFromFile(cr_settings_file)
177 self.updated = True
178
179 def GetDefaultServerUrl(self, error_ok=False):
180 if not self.default_server:
181 self.LazyUpdateIfNeeded()
182 self.default_server = FixUrl(self._GetConfig('rietveld.server',
183 error_ok=True))
184 if error_ok:
185 return self.default_server
186 if not self.default_server:
187 error_message = ('Could not find settings file. You must configure '
188 'your review setup by running "git cl config".')
189 self.default_server = FixUrl(self._GetConfig(
190 'rietveld.server', error_message=error_message))
191 return self.default_server
192
193 def GetCCList(self):
194 """Return the users cc'd on this CL.
195
196 Return is a string suitable for passing to gcl with the --cc flag.
197 """
198 if self.cc is None:
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000199 base_cc = self._GetConfig('rietveld.cc', error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000200 more_cc = self._GetConfig('rietveld.extracc', error_ok=True)
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000201 self.cc = ','.join(filter(None, (base_cc, more_cc))) or ''
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000202 return self.cc
203
204 def GetRoot(self):
205 if not self.root:
206 self.root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
207 return self.root
208
209 def GetIsGitSvn(self):
210 """Return true if this repo looks like it's using git-svn."""
211 if self.is_git_svn is None:
212 # If you have any "svn-remote.*" config keys, we think you're using svn.
213 self.is_git_svn = RunGitWithCode(
214 ['config', '--get-regexp', r'^svn-remote\.'])[0] == 0
215 return self.is_git_svn
216
217 def GetSVNBranch(self):
218 if self.svn_branch is None:
219 if not self.GetIsGitSvn():
220 DieWithError('Repo doesn\'t appear to be a git-svn repo.')
221
222 # Try to figure out which remote branch we're based on.
223 # Strategy:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000224 # 1) iterate through our branch history and find the svn URL.
225 # 2) find the svn-remote that fetches from the URL.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000226
227 # regexp matching the git-svn line that contains the URL.
228 git_svn_re = re.compile(r'^\s*git-svn-id: (\S+)@', re.MULTILINE)
229
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000230 # We don't want to go through all of history, so read a line from the
231 # pipe at a time.
232 # The -100 is an arbitrary limit so we don't search forever.
233 cmd = ['git', 'log', '-100', '--pretty=medium']
234 proc = Popen(cmd, stdout=subprocess.PIPE)
235 for line in proc.stdout:
236 match = git_svn_re.match(line)
237 if match:
238 url = match.group(1)
239 proc.stdout.close() # Cut pipe.
240 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000241
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000242 if url:
243 svn_remote_re = re.compile(r'^svn-remote\.([^.]+)\.url (.*)$')
244 remotes = RunGit(['config', '--get-regexp',
245 r'^svn-remote\..*\.url']).splitlines()
246 for remote in remotes:
247 match = svn_remote_re.match(remote)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000248 if match:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000249 remote = match.group(1)
250 base_url = match.group(2)
251 fetch_spec = RunGit(
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000252 ['config', 'svn-remote.%s.fetch' % remote],
253 error_ok=True).strip()
254 if fetch_spec:
255 self.svn_branch = MatchSvnGlob(url, base_url, fetch_spec, False)
256 if self.svn_branch:
257 break
258 branch_spec = RunGit(
259 ['config', 'svn-remote.%s.branches' % remote],
260 error_ok=True).strip()
261 if branch_spec:
262 self.svn_branch = MatchSvnGlob(url, base_url, branch_spec, True)
263 if self.svn_branch:
264 break
265 tag_spec = RunGit(
266 ['config', 'svn-remote.%s.tags' % remote],
267 error_ok=True).strip()
268 if tag_spec:
269 self.svn_branch = MatchSvnGlob(url, base_url, tag_spec, True)
270 if self.svn_branch:
271 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000272
273 if not self.svn_branch:
274 DieWithError('Can\'t guess svn branch -- try specifying it on the '
275 'command line')
276
277 return self.svn_branch
278
279 def GetTreeStatusUrl(self, error_ok=False):
280 if not self.tree_status_url:
281 error_message = ('You must configure your tree status URL by running '
282 '"git cl config".')
283 self.tree_status_url = self._GetConfig('rietveld.tree-status-url',
284 error_ok=error_ok,
285 error_message=error_message)
286 return self.tree_status_url
287
288 def GetViewVCUrl(self):
289 if not self.viewvc_url:
290 self.viewvc_url = self._GetConfig('rietveld.viewvc-url', error_ok=True)
291 return self.viewvc_url
292
293 def _GetConfig(self, param, **kwargs):
294 self.LazyUpdateIfNeeded()
295 return RunGit(['config', param], **kwargs).strip()
296
297
298settings = Settings()
299
300
301did_migrate_check = False
302def CheckForMigration():
303 """Migrate from the old issue format, if found.
304
305 We used to store the branch<->issue mapping in a file in .git, but it's
306 better to store it in the .git/config, since deleting a branch deletes that
307 branch's entry there.
308 """
309
310 # Don't run more than once.
311 global did_migrate_check
312 if did_migrate_check:
313 return
314
315 gitdir = RunGit(['rev-parse', '--git-dir']).strip()
316 storepath = os.path.join(gitdir, 'cl-mapping')
317 if os.path.exists(storepath):
318 print "old-style git-cl mapping file (%s) found; migrating." % storepath
319 store = open(storepath, 'r')
320 for line in store:
321 branch, issue = line.strip().split()
322 RunGit(['config', 'branch.%s.rietveldissue' % ShortBranchName(branch),
323 issue])
324 store.close()
325 os.remove(storepath)
326 did_migrate_check = True
327
328
329def ShortBranchName(branch):
330 """Convert a name like 'refs/heads/foo' to just 'foo'."""
331 return branch.replace('refs/heads/', '')
332
333
334class Changelist(object):
335 def __init__(self, branchref=None):
336 # Poke settings so we get the "configure your server" message if necessary.
337 settings.GetDefaultServerUrl()
338 self.branchref = branchref
339 if self.branchref:
340 self.branch = ShortBranchName(self.branchref)
341 else:
342 self.branch = None
343 self.rietveld_server = None
344 self.upstream_branch = None
345 self.has_issue = False
346 self.issue = None
347 self.has_description = False
348 self.description = None
349 self.has_patchset = False
350 self.patchset = None
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000351 self._rpc_server = None
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000352
353 def GetBranch(self):
354 """Returns the short branch name, e.g. 'master'."""
355 if not self.branch:
356 self.branchref = RunGit(['symbolic-ref', 'HEAD']).strip()
357 self.branch = ShortBranchName(self.branchref)
358 return self.branch
359
360 def GetBranchRef(self):
361 """Returns the full branch name, e.g. 'refs/heads/master'."""
362 self.GetBranch() # Poke the lazy loader.
363 return self.branchref
364
365 def FetchUpstreamTuple(self):
366 """Returns a tuple containg remote and remote ref,
367 e.g. 'origin', 'refs/heads/master'
368 """
369 remote = '.'
370 branch = self.GetBranch()
371 upstream_branch = RunGit(['config', 'branch.%s.merge' % branch],
372 error_ok=True).strip()
373 if upstream_branch:
374 remote = RunGit(['config', 'branch.%s.remote' % branch]).strip()
375 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000376 upstream_branch = RunGit(['config', 'rietveld.upstream-branch'],
377 error_ok=True).strip()
378 if upstream_branch:
379 remote = RunGit(['config', 'rietveld.upstream-remote']).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000380 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000381 # Fall back on trying a git-svn upstream branch.
382 if settings.GetIsGitSvn():
383 upstream_branch = settings.GetSVNBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000384 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000385 # Else, try to guess the origin remote.
386 remote_branches = RunGit(['branch', '-r']).split()
387 if 'origin/master' in remote_branches:
388 # Fall back on origin/master if it exits.
389 remote = 'origin'
390 upstream_branch = 'refs/heads/master'
391 elif 'origin/trunk' in remote_branches:
392 # Fall back on origin/trunk if it exists. Generally a shared
393 # git-svn clone
394 remote = 'origin'
395 upstream_branch = 'refs/heads/trunk'
396 else:
397 DieWithError("""Unable to determine default branch to diff against.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000398Either pass complete "git diff"-style arguments, like
399 git cl upload origin/master
400or verify this branch is set up to track another (via the --track argument to
401"git checkout -b ...").""")
402
403 return remote, upstream_branch
404
405 def GetUpstreamBranch(self):
406 if self.upstream_branch is None:
407 remote, upstream_branch = self.FetchUpstreamTuple()
408 if remote is not '.':
409 upstream_branch = upstream_branch.replace('heads', 'remotes/' + remote)
410 self.upstream_branch = upstream_branch
411 return self.upstream_branch
412
413 def GetRemoteUrl(self):
414 """Return the configured remote URL, e.g. 'git://example.org/foo.git/'.
415
416 Returns None if there is no remote.
417 """
418 remote = self.FetchUpstreamTuple()[0]
419 if remote == '.':
420 return None
421 return RunGit(['config', 'remote.%s.url' % remote], error_ok=True).strip()
422
423 def GetIssue(self):
424 if not self.has_issue:
425 CheckForMigration()
426 issue = RunGit(['config', self._IssueSetting()], error_ok=True).strip()
427 if issue:
428 self.issue = issue
429 self.rietveld_server = FixUrl(RunGit(
430 ['config', self._RietveldServer()], error_ok=True).strip())
431 else:
432 self.issue = None
433 if not self.rietveld_server:
434 self.rietveld_server = settings.GetDefaultServerUrl()
435 self.has_issue = True
436 return self.issue
437
438 def GetRietveldServer(self):
439 self.GetIssue()
440 return self.rietveld_server
441
442 def GetIssueURL(self):
443 """Get the URL for a particular issue."""
444 return '%s/%s' % (self.GetRietveldServer(), self.GetIssue())
445
446 def GetDescription(self, pretty=False):
447 if not self.has_description:
448 if self.GetIssue():
449 path = '/' + self.GetIssue() + '/description'
450 rpc_server = self._RpcServer()
451 self.description = rpc_server.Send(path).strip()
452 self.has_description = True
453 if pretty:
454 wrapper = textwrap.TextWrapper()
455 wrapper.initial_indent = wrapper.subsequent_indent = ' '
456 return wrapper.fill(self.description)
457 return self.description
458
459 def GetPatchset(self):
460 if not self.has_patchset:
461 patchset = RunGit(['config', self._PatchsetSetting()],
462 error_ok=True).strip()
463 if patchset:
464 self.patchset = patchset
465 else:
466 self.patchset = None
467 self.has_patchset = True
468 return self.patchset
469
470 def SetPatchset(self, patchset):
471 """Set this branch's patchset. If patchset=0, clears the patchset."""
472 if patchset:
473 RunGit(['config', self._PatchsetSetting(), str(patchset)])
474 else:
475 RunGit(['config', '--unset', self._PatchsetSetting()],
476 swallow_stderr=True, error_ok=True)
477 self.has_patchset = False
478
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000479 def GetPatchSetDiff(self, issue):
480 # Grab the last patchset of the issue first.
481 data = json.loads(self._RpcServer().Send('/api/%s' % issue))
482 patchset = data['patchsets'][-1]
483 return self._RpcServer().Send(
484 '/download/issue%s_%s.diff' % (issue, patchset))
485
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000486 def SetIssue(self, issue):
487 """Set this branch's issue. If issue=0, clears the issue."""
488 if issue:
489 RunGit(['config', self._IssueSetting(), str(issue)])
490 if self.rietveld_server:
491 RunGit(['config', self._RietveldServer(), self.rietveld_server])
492 else:
493 RunGit(['config', '--unset', self._IssueSetting()])
494 self.SetPatchset(0)
495 self.has_issue = False
496
497 def CloseIssue(self):
498 rpc_server = self._RpcServer()
499 # Newer versions of Rietveld require us to pass an XSRF token to POST, so
500 # we fetch it from the server. (The version used by Chromium has been
501 # modified so the token isn't required when closing an issue.)
502 xsrf_token = rpc_server.Send('/xsrf_token',
503 extra_headers={'X-Requesting-XSRF-Token': '1'})
504
505 # You cannot close an issue with a GET.
506 # We pass an empty string for the data so it is a POST rather than a GET.
507 data = [("description", self.description),
508 ("xsrf_token", xsrf_token)]
509 ctype, body = upload.EncodeMultipartFormData(data, [])
510 rpc_server.Send('/' + self.GetIssue() + '/close', body, ctype)
511
512 def _RpcServer(self):
513 """Returns an upload.RpcServer() to access this review's rietveld instance.
514 """
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000515 if not self._rpc_server:
516 server = self.GetRietveldServer()
517 self._rpc_server = upload.GetRpcServer(server, save_cookies=True)
518 return self._rpc_server
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000519
520 def _IssueSetting(self):
521 """Return the git setting that stores this change's issue."""
522 return 'branch.%s.rietveldissue' % self.GetBranch()
523
524 def _PatchsetSetting(self):
525 """Return the git setting that stores this change's most recent patchset."""
526 return 'branch.%s.rietveldpatchset' % self.GetBranch()
527
528 def _RietveldServer(self):
529 """Returns the git setting that stores this change's rietveld server."""
530 return 'branch.%s.rietveldserver' % self.GetBranch()
531
532
533def GetCodereviewSettingsInteractively():
534 """Prompt the user for settings."""
535 server = settings.GetDefaultServerUrl(error_ok=True)
536 prompt = 'Rietveld server (host[:port])'
537 prompt += ' [%s]' % (server or DEFAULT_SERVER)
maruel@chromium.org90541732011-04-01 17:54:18 +0000538 newserver = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000539 if not server and not newserver:
540 newserver = DEFAULT_SERVER
541 if newserver and newserver != server:
542 RunGit(['config', 'rietveld.server', newserver])
543
544 def SetProperty(initial, caption, name):
545 prompt = caption
546 if initial:
547 prompt += ' ("x" to clear) [%s]' % initial
maruel@chromium.org90541732011-04-01 17:54:18 +0000548 new_val = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000549 if new_val == 'x':
550 RunGit(['config', '--unset-all', 'rietveld.' + name], error_ok=True)
551 elif new_val and new_val != initial:
552 RunGit(['config', 'rietveld.' + name, new_val])
553
554 SetProperty(settings.GetCCList(), 'CC list', 'cc')
555 SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL',
556 'tree-status-url')
557 SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url')
558
559 # TODO: configure a default branch to diff against, rather than this
560 # svn-based hackery.
561
562
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000563class ChangeDescription(object):
564 """Contains a parsed form of the change description."""
565 def __init__(self, subject, log_desc, reviewers):
566 self.subject = subject
567 self.log_desc = log_desc
568 self.reviewers = reviewers
569 self.description = self.log_desc
570
571 def Update(self):
572 initial_text = """# Enter a description of the change.
573# This will displayed on the codereview site.
574# The first line will also be used as the subject of the review.
575"""
576 initial_text += self.description
577 if 'R=' not in self.description and self.reviewers:
578 initial_text += '\nR=' + self.reviewers
579 if 'BUG=' not in self.description:
580 initial_text += '\nBUG='
581 if 'TEST=' not in self.description:
582 initial_text += '\nTEST='
583 self._ParseDescription(UserEditedLog(initial_text))
584
585 def _ParseDescription(self, description):
586 if not description:
587 self.description = description
588 return
589
590 parsed_lines = []
591 reviewers_regexp = re.compile('\s*R=(.+)')
592 reviewers = ''
593 subject = ''
594 for l in description.splitlines():
595 if not subject:
596 subject = l
597 matched_reviewers = reviewers_regexp.match(l)
598 if matched_reviewers:
599 reviewers = matched_reviewers.group(1)
600 parsed_lines.append(l)
601
602 self.description = '\n'.join(parsed_lines) + '\n'
603 self.subject = subject
604 self.reviewers = reviewers
605
606 def IsEmpty(self):
607 return not self.description
608
609
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000610def FindCodereviewSettingsFile(filename='codereview.settings'):
611 """Finds the given file starting in the cwd and going up.
612
613 Only looks up to the top of the repository unless an
614 'inherit-review-settings-ok' file exists in the root of the repository.
615 """
616 inherit_ok_file = 'inherit-review-settings-ok'
617 cwd = os.getcwd()
618 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
619 if os.path.isfile(os.path.join(root, inherit_ok_file)):
620 root = '/'
621 while True:
622 if filename in os.listdir(cwd):
623 if os.path.isfile(os.path.join(cwd, filename)):
624 return open(os.path.join(cwd, filename))
625 if cwd == root:
626 break
627 cwd = os.path.dirname(cwd)
628
629
630def LoadCodereviewSettingsFromFile(fileobj):
631 """Parse a codereview.settings file and updates hooks."""
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000632 keyvals = {}
633 for line in fileobj.read().splitlines():
634 if not line or line.startswith("#"):
635 continue
636 k, v = line.split(": ", 1)
637 keyvals[k] = v
638
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000639 def SetProperty(name, setting, unset_error_ok=False):
640 fullname = 'rietveld.' + name
641 if setting in keyvals:
642 RunGit(['config', fullname, keyvals[setting]])
643 else:
644 RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
645
646 SetProperty('server', 'CODE_REVIEW_SERVER')
647 # Only server setting is required. Other settings can be absent.
648 # In that case, we ignore errors raised during option deletion attempt.
649 SetProperty('cc', 'CC_LIST', unset_error_ok=True)
650 SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
651 SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
652
653 if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
654 #should be of the form
655 #PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
656 #ORIGIN_URL_CONFIG: http://src.chromium.org/git
657 RunGit(['config', keyvals['PUSH_URL_CONFIG'],
658 keyvals['ORIGIN_URL_CONFIG']])
659
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000660
661@usage('[repo root containing codereview.settings]')
662def CMDconfig(parser, args):
663 """edit configuration for this tree"""
664
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000665 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000666 if len(args) == 0:
667 GetCodereviewSettingsInteractively()
668 return 0
669
670 url = args[0]
671 if not url.endswith('codereview.settings'):
672 url = os.path.join(url, 'codereview.settings')
673
674 # Load code review settings and download hooks (if available).
675 LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
676 return 0
677
678
679def CMDstatus(parser, args):
680 """show status of changelists"""
681 parser.add_option('--field',
682 help='print only specific field (desc|id|patch|url)')
683 (options, args) = parser.parse_args(args)
684
685 # TODO: maybe make show_branches a flag if necessary.
686 show_branches = not options.field
687
688 if show_branches:
689 branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
690 if branches:
691 print 'Branches associated with reviews:'
692 for branch in sorted(branches.splitlines()):
693 cl = Changelist(branchref=branch)
694 print " %10s: %s" % (cl.GetBranch(), cl.GetIssue())
695
696 cl = Changelist()
697 if options.field:
698 if options.field.startswith('desc'):
699 print cl.GetDescription()
700 elif options.field == 'id':
701 issueid = cl.GetIssue()
702 if issueid:
703 print issueid
704 elif options.field == 'patch':
705 patchset = cl.GetPatchset()
706 if patchset:
707 print patchset
708 elif options.field == 'url':
709 url = cl.GetIssueURL()
710 if url:
711 print url
712 else:
713 print
714 print 'Current branch:',
715 if not cl.GetIssue():
716 print 'no issue assigned.'
717 return 0
718 print cl.GetBranch()
719 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
720 print 'Issue description:'
721 print cl.GetDescription(pretty=True)
722 return 0
723
724
725@usage('[issue_number]')
726def CMDissue(parser, args):
727 """Set or display the current code review issue number.
728
729 Pass issue number 0 to clear the current issue.
730"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000731 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000732
733 cl = Changelist()
734 if len(args) > 0:
735 try:
736 issue = int(args[0])
737 except ValueError:
738 DieWithError('Pass a number to set the issue or none to list it.\n'
739 'Maybe you want to run git cl status?')
740 cl.SetIssue(issue)
741 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
742 return 0
743
744
745def CreateDescriptionFromLog(args):
746 """Pulls out the commit log to use as a base for the CL description."""
747 log_args = []
748 if len(args) == 1 and not args[0].endswith('.'):
749 log_args = [args[0] + '..']
750 elif len(args) == 1 and args[0].endswith('...'):
751 log_args = [args[0][:-1]]
752 elif len(args) == 2:
753 log_args = [args[0] + '..' + args[1]]
754 else:
755 log_args = args[:] # Hope for the best!
756 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
757
758
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000759def UserEditedLog(starting_text):
760 """Given some starting text, let the user edit it and return the result."""
761 editor = os.getenv('EDITOR', 'vi')
762
763 (file_handle, filename) = tempfile.mkstemp()
764 fileobj = os.fdopen(file_handle, 'w')
765 fileobj.write(starting_text)
766 fileobj.close()
767
768 # Open up the default editor in the system to get the CL description.
769 try:
770 cmd = '%s %s' % (editor, filename)
771 if sys.platform == 'win32' and os.environ.get('TERM') == 'msys':
772 # Msysgit requires the usage of 'env' to be present.
773 cmd = 'env ' + cmd
774 # shell=True to allow the shell to handle all forms of quotes in $EDITOR.
775 subprocess.check_call(cmd, shell=True)
776 fileobj = open(filename)
777 text = fileobj.read()
778 fileobj.close()
779 finally:
780 os.remove(filename)
781
782 if not text:
783 return
784
785 stripcomment_re = re.compile(r'^#.*$', re.MULTILINE)
786 return stripcomment_re.sub('', text).strip()
787
788
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000789def ConvertToInteger(inputval):
790 """Convert a string to integer, but returns either an int or None."""
791 try:
792 return int(inputval)
793 except (TypeError, ValueError):
794 return None
795
796
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000797def RunHook(committing, upstream_branch, rietveld_server, tbr, may_prompt):
798 """Calls sys.exit() if the hook fails; returns a HookResults otherwise."""
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000799 root = RunCommand(['git', 'rev-parse', '--show-cdup']).strip()
800 if not root:
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000801 root = '.'
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000802 absroot = os.path.abspath(root)
803 if not root:
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000804 raise Exception('Could not get root directory.')
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000805
806 # We use the sha1 of HEAD as a name of this change.
807 name = RunCommand(['git', 'rev-parse', 'HEAD']).strip()
808 files = scm.GIT.CaptureStatus([root], upstream_branch)
809
810 cl = Changelist()
811 issue = ConvertToInteger(cl.GetIssue())
812 patchset = ConvertToInteger(cl.GetPatchset())
813 if issue:
814 description = cl.GetDescription()
815 else:
816 # If the change was never uploaded, use the log messages of all commits
817 # up to the branch point, as git cl upload will prefill the description
818 # with these log messages.
819 description = RunCommand(['git', 'log', '--pretty=format:%s%n%n%b',
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000820 '%s...' % (upstream_branch)]).strip()
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000821 change = presubmit_support.GitChange(name, description, absroot, files,
822 issue, patchset)
823
824 # Apply watchlists on upload.
825 if not committing:
826 watchlist = watchlists.Watchlists(change.RepositoryRoot())
827 files = [f.LocalPath() for f in change.AffectedFiles()]
828 watchers = watchlist.GetWatchersForPaths(files)
829 RunCommand(['git', 'config', '--replace-all',
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000830 'rietveld.extracc', ','.join(watchers)])
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000831
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000832 output = presubmit_support.DoPresubmitChecks(change, committing,
833 verbose=False, output_stream=sys.stdout, input_stream=sys.stdin,
834 default_presubmit=None, may_prompt=may_prompt, tbr=tbr,
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000835 host_url=cl.GetRietveldServer())
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000836
837 # TODO(dpranke): We should propagate the error out instead of calling exit().
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000838 if not output.should_continue():
839 sys.exit(1)
dpranke@chromium.org37248c02011-03-12 04:36:48 +0000840
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000841 return output
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000842
843
844def CMDpresubmit(parser, args):
845 """run presubmit tests on the current changelist"""
846 parser.add_option('--upload', action='store_true',
847 help='Run upload hook instead of the push/dcommit hook')
848 (options, args) = parser.parse_args(args)
849
850 # Make sure index is up-to-date before running diff-index.
851 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
852 if RunGit(['diff-index', 'HEAD']):
853 # TODO(maruel): Is this really necessary?
854 print 'Cannot presubmit with a dirty tree. You must commit locally first.'
855 return 1
856
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000857 cl = Changelist()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000858 if args:
859 base_branch = args[0]
860 else:
861 # Default to diffing against the "upstream" branch.
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000862 base_branch = cl.GetUpstreamBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000863
dpranke@chromium.org0a2bb372011-03-25 01:16:22 +0000864 RunHook(committing=not options.upload, upstream_branch=base_branch,
865 rietveld_server=cl.GetRietveldServer(), tbr=False,
866 may_prompt=False)
867 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000868
869
870@usage('[args to "git diff"]')
871def CMDupload(parser, args):
872 """upload the current changelist to codereview"""
873 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
874 help='bypass upload presubmit hook')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000875 parser.add_option('-f', action='store_true', dest='force',
876 help="force yes to questions (don't prompt)")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000877 parser.add_option('-m', dest='message', help='message for patch')
878 parser.add_option('-r', '--reviewers',
879 help='reviewer email addresses')
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000880 parser.add_option('--cc',
881 help='cc email addresses')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000882 parser.add_option('--send-mail', action='store_true',
883 help='send email to reviewer immediately')
884 parser.add_option("--emulate_svn_auto_props", action="store_true",
885 dest="emulate_svn_auto_props",
886 help="Emulate Subversion's auto properties feature.")
887 parser.add_option("--desc_from_logs", action="store_true",
888 dest="from_logs",
889 help="""Squashes git commit logs into change description and
890 uses message as subject""")
891 (options, args) = parser.parse_args(args)
892
893 # Make sure index is up-to-date before running diff-index.
894 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
895 if RunGit(['diff-index', 'HEAD']):
896 print 'Cannot upload with a dirty tree. You must commit locally first.'
897 return 1
898
899 cl = Changelist()
900 if args:
901 base_branch = args[0]
902 else:
903 # Default to diffing against the "upstream" branch.
904 base_branch = cl.GetUpstreamBranch()
905 args = [base_branch + "..."]
906
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000907 if not options.bypass_hooks and not options.force:
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000908 hook_results = RunHook(committing=False, upstream_branch=base_branch,
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000909 rietveld_server=cl.GetRietveldServer(), tbr=False,
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000910 may_prompt=True)
911 if not options.reviewers and hook_results.reviewers:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000912 options.reviewers = hook_results.reviewers
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000913
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000914
915 # --no-ext-diff is broken in some versions of Git, so try to work around
916 # this by overriding the environment (but there is still a problem if the
917 # git config key "diff.external" is used).
918 env = os.environ.copy()
919 if 'GIT_EXTERNAL_DIFF' in env:
920 del env['GIT_EXTERNAL_DIFF']
921 subprocess.call(['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args,
922 env=env)
923
924 upload_args = ['--assume_yes'] # Don't ask about untracked files.
925 upload_args.extend(['--server', cl.GetRietveldServer()])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000926 if options.emulate_svn_auto_props:
927 upload_args.append('--emulate_svn_auto_props')
928 if options.send_mail:
929 if not options.reviewers:
930 DieWithError("Must specify reviewers to send email.")
931 upload_args.append('--send_mail')
932 if options.from_logs and not options.message:
933 print 'Must set message for subject line if using desc_from_logs'
934 return 1
935
936 change_desc = None
937
938 if cl.GetIssue():
939 if options.message:
940 upload_args.extend(['--message', options.message])
941 upload_args.extend(['--issue', cl.GetIssue()])
942 print ("This branch is associated with issue %s. "
943 "Adding patch to that issue." % cl.GetIssue())
944 else:
945 log_desc = CreateDescriptionFromLog(args)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000946 change_desc = ChangeDescription(options.message, log_desc,
947 options.reviewers)
948 if not options.from_logs:
949 change_desc.Update()
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000950
951 if change_desc.IsEmpty():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000952 print "Description is empty; aborting."
953 return 1
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000954
955 upload_args.extend(['--message', change_desc.subject])
956 upload_args.extend(['--description', change_desc.description])
957 if change_desc.reviewers:
958 upload_args.extend(['--reviewers', change_desc.reviewers])
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000959 cc = ','.join(filter(None, (settings.GetCCList(), options.cc)))
960 if cc:
961 upload_args.extend(['--cc', cc])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000962
963 # Include the upstream repo's URL in the change -- this is useful for
964 # projects that have their source spread across multiple repos.
965 remote_url = None
966 if settings.GetIsGitSvn():
maruel@chromium.orgb92e4802011-03-03 20:22:00 +0000967 # URL is dependent on the current directory.
968 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000969 if data:
970 keys = dict(line.split(': ', 1) for line in data.splitlines()
971 if ': ' in line)
972 remote_url = keys.get('URL', None)
973 else:
974 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
975 remote_url = (cl.GetRemoteUrl() + '@'
976 + cl.GetUpstreamBranch().split('/')[-1])
977 if remote_url:
978 upload_args.extend(['--base_url', remote_url])
979
980 try:
981 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
982 except:
983 # If we got an exception after the user typed a description for their
984 # change, back up the description before re-raising.
985 if change_desc:
986 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
987 print '\nGot exception while uploading -- saving description to %s\n' \
988 % backup_path
989 backup_file = open(backup_path, 'w')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000990 backup_file.write(change_desc.description)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000991 backup_file.close()
992 raise
993
994 if not cl.GetIssue():
995 cl.SetIssue(issue)
996 cl.SetPatchset(patchset)
997 return 0
998
999
1000def SendUpstream(parser, args, cmd):
1001 """Common code for CmdPush and CmdDCommit
1002
1003 Squashed commit into a single.
1004 Updates changelog with metadata (e.g. pointer to review).
1005 Pushes/dcommits the code upstream.
1006 Updates review and closes.
1007 """
1008 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
1009 help='bypass upload presubmit hook')
1010 parser.add_option('-m', dest='message',
1011 help="override review description")
1012 parser.add_option('-f', action='store_true', dest='force',
1013 help="force yes to questions (don't prompt)")
1014 parser.add_option('-c', dest='contributor',
1015 help="external contributor for patch (appended to " +
1016 "description and used as author for git). Should be " +
1017 "formatted as 'First Last <email@example.com>'")
1018 parser.add_option('--tbr', action='store_true', dest='tbr',
1019 help="short for 'to be reviewed', commit branch " +
1020 "even without uploading for review")
1021 (options, args) = parser.parse_args(args)
1022 cl = Changelist()
1023
1024 if not args or cmd == 'push':
1025 # Default to merging against our best guess of the upstream branch.
1026 args = [cl.GetUpstreamBranch()]
1027
1028 base_branch = args[0]
1029
chase@chromium.orgc76e6752011-01-10 18:17:12 +00001030 # Make sure index is up-to-date before running diff-index.
1031 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001032 if RunGit(['diff-index', 'HEAD']):
1033 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
1034 return 1
1035
1036 # This rev-list syntax means "show all commits not in my branch that
1037 # are in base_branch".
1038 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
1039 base_branch]).splitlines()
1040 if upstream_commits:
1041 print ('Base branch "%s" has %d commits '
1042 'not in this branch.' % (base_branch, len(upstream_commits)))
1043 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
1044 return 1
1045
1046 if cmd == 'dcommit':
1047 # This is the revision `svn dcommit` will commit on top of.
1048 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
1049 '--pretty=format:%H'])
1050 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
1051 if extra_commits:
1052 print ('This branch has %d additional commits not upstreamed yet.'
1053 % len(extra_commits.splitlines()))
1054 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
1055 'before attempting to %s.' % (base_branch, cmd))
1056 return 1
1057
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001058 if not options.bypass_hooks and not options.force:
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001059 RunHook(committing=True, upstream_branch=base_branch,
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001060 rietveld_server=cl.GetRietveldServer(), tbr=options.tbr,
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001061 may_prompt=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001062
1063 if cmd == 'dcommit':
1064 # Check the tree status if the tree status URL is set.
1065 status = GetTreeStatus()
1066 if 'closed' == status:
1067 print ('The tree is closed. Please wait for it to reopen. Use '
1068 '"git cl dcommit -f" to commit on a closed tree.')
1069 return 1
1070 elif 'unknown' == status:
1071 print ('Unable to determine tree status. Please verify manually and '
1072 'use "git cl dcommit -f" to commit on a closed tree.')
1073
1074 description = options.message
1075 if not options.tbr:
1076 # It is important to have these checks early. Not only for user
1077 # convenience, but also because the cl object then caches the correct values
1078 # of these fields even as we're juggling branches for setting up the commit.
1079 if not cl.GetIssue():
1080 print 'Current issue unknown -- has this branch been uploaded?'
1081 print 'Use --tbr to commit without review.'
1082 return 1
1083
1084 if not description:
1085 description = cl.GetDescription()
1086
1087 if not description:
1088 print 'No description set.'
1089 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
1090 return 1
1091
1092 description += "\n\nReview URL: %s" % cl.GetIssueURL()
1093 else:
1094 if not description:
1095 # Submitting TBR. See if there's already a description in Rietveld, else
1096 # create a template description. Eitherway, give the user a chance to edit
1097 # it to fill in the TBR= field.
1098 if cl.GetIssue():
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001099 description = cl.GetDescription()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001100
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001101 # TODO(dpranke): Update to use ChangeDescription object.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001102 if not description:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001103 description = """# Enter a description of the change.
1104# This will be used as the change log for the commit.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001105
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001106"""
1107 description += CreateDescriptionFromLog(args)
1108
1109 description = UserEditedLog(description + '\nTBR=')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001110
1111 if not description:
1112 print "Description empty; aborting."
1113 return 1
1114
1115 if options.contributor:
1116 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
1117 print "Please provide contibutor as 'First Last <email@example.com>'"
1118 return 1
1119 description += "\nPatch from %s." % options.contributor
1120 print 'Description:', repr(description)
1121
1122 branches = [base_branch, cl.GetBranchRef()]
1123 if not options.force:
1124 subprocess.call(['git', 'diff', '--stat'] + branches)
maruel@chromium.org90541732011-04-01 17:54:18 +00001125 ask_for_data('About to commit; enter to confirm.')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001126
1127 # We want to squash all this branch's commits into one commit with the
1128 # proper description.
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001129 # We do this by doing a "reset --soft" to the base branch (which keeps
1130 # the working copy the same), then dcommitting that.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001131 MERGE_BRANCH = 'git-cl-commit'
1132 # Delete the merge branch if it already exists.
1133 if RunGitWithCode(['show-ref', '--quiet', '--verify',
1134 'refs/heads/' + MERGE_BRANCH])[0] == 0:
1135 RunGit(['branch', '-D', MERGE_BRANCH])
1136
1137 # We might be in a directory that's present in this branch but not in the
1138 # trunk. Move up to the top of the tree so that git commands that expect a
1139 # valid CWD won't fail after we check out the merge branch.
1140 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
1141 if rel_base_path:
1142 os.chdir(rel_base_path)
1143
1144 # Stuff our change into the merge branch.
1145 # We wrap in a try...finally block so if anything goes wrong,
1146 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001147 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001148 try:
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001149 RunGit(['checkout', '-q', '-b', MERGE_BRANCH])
1150 RunGit(['reset', '--soft', base_branch])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001151 if options.contributor:
1152 RunGit(['commit', '--author', options.contributor, '-m', description])
1153 else:
1154 RunGit(['commit', '-m', description])
1155 if cmd == 'push':
1156 # push the merge branch.
1157 remote, branch = cl.FetchUpstreamTuple()
1158 retcode, output = RunGitWithCode(
1159 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1160 logging.debug(output)
1161 else:
1162 # dcommit the merge branch.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001163 retcode, output = RunGitWithCode(['svn', 'dcommit', '--no-rebase'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001164 finally:
1165 # And then swap back to the original branch and clean up.
1166 RunGit(['checkout', '-q', cl.GetBranch()])
1167 RunGit(['branch', '-D', MERGE_BRANCH])
1168
1169 if cl.GetIssue():
1170 if cmd == 'dcommit' and 'Committed r' in output:
1171 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1172 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001173 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1174 for l in output.splitlines(False))
1175 match = filter(None, match)
1176 if len(match) != 1:
1177 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1178 output)
1179 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001180 else:
1181 return 1
1182 viewvc_url = settings.GetViewVCUrl()
1183 if viewvc_url and revision:
1184 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1185 print ('Closing issue '
1186 '(you may be prompted for your codereview password)...')
1187 cl.CloseIssue()
1188 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001189
1190 if retcode == 0:
1191 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1192 if os.path.isfile(hook):
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001193 RunCommand([hook, base_branch], error_ok=True)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001194
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001195 return 0
1196
1197
1198@usage('[upstream branch to apply against]')
1199def CMDdcommit(parser, args):
1200 """commit the current changelist via git-svn"""
1201 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001202 message = """This doesn't appear to be an SVN repository.
1203If your project has a git mirror with an upstream SVN master, you probably need
1204to run 'git svn init', see your project's git mirror documentation.
1205If your project has a true writeable upstream repository, you probably want
1206to run 'git cl push' instead.
1207Choose wisely, if you get this wrong, your commit might appear to succeed but
1208will instead be silently ignored."""
1209 print(message)
maruel@chromium.org90541732011-04-01 17:54:18 +00001210 ask_for_data('[Press enter to dcommit or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001211 return SendUpstream(parser, args, 'dcommit')
1212
1213
1214@usage('[upstream branch to apply against]')
1215def CMDpush(parser, args):
1216 """commit the current changelist via git"""
1217 if settings.GetIsGitSvn():
1218 print('This appears to be an SVN repository.')
1219 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
maruel@chromium.org90541732011-04-01 17:54:18 +00001220 ask_for_data('[Press enter to push or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001221 return SendUpstream(parser, args, 'push')
1222
1223
1224@usage('<patch url or issue id>')
1225def CMDpatch(parser, args):
1226 """patch in a code review"""
1227 parser.add_option('-b', dest='newbranch',
1228 help='create a new branch off trunk for the patch')
1229 parser.add_option('-f', action='store_true', dest='force',
1230 help='with -b, clobber any existing branch')
1231 parser.add_option('--reject', action='store_true', dest='reject',
1232 help='allow failed patches and spew .rej files')
1233 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1234 help="don't commit after patch applies")
1235 (options, args) = parser.parse_args(args)
1236 if len(args) != 1:
1237 parser.print_help()
1238 return 1
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001239 issue_arg = args[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001240
dpranke@chromium.org6a2d0832011-03-18 05:28:42 +00001241 if re.match(r'\d+', issue_arg):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001242 # Input is an issue id. Figure out the URL.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001243 issue = issue_arg
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001244 patch_data = Changelist().GetPatchSetDiff(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001245 else:
1246 # Assume it's a URL to the patch. Default to http.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001247 issue_url = FixUrl(issue_arg)
1248 match = re.match(r'.*?/issue(\d+)_\d+.diff', issue_url)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001249 if not match:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001250 DieWithError('Must pass an issue ID or full URL for '
1251 '\'Download raw patch set\'')
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001252 issue = match.group(1)
1253 patch_data = urllib2.urlopen(issue_arg).read()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001254
1255 if options.newbranch:
1256 if options.force:
1257 RunGit(['branch', '-D', options.newbranch],
1258 swallow_stderr=True, error_ok=True)
1259 RunGit(['checkout', '-b', options.newbranch,
1260 Changelist().GetUpstreamBranch()])
1261
1262 # Switch up to the top-level directory, if necessary, in preparation for
1263 # applying the patch.
1264 top = RunGit(['rev-parse', '--show-cdup']).strip()
1265 if top:
1266 os.chdir(top)
1267
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001268 # Git patches have a/ at the beginning of source paths. We strip that out
1269 # with a sed script rather than the -p flag to patch so we can feed either
1270 # Git or svn-style patches into the same apply command.
1271 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
1272 sed_proc = Popen(['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'],
1273 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
1274 patch_data = sed_proc.communicate(patch_data)[0]
1275 if sed_proc.returncode:
1276 DieWithError('Git patch mungling failed.')
1277 logging.info(patch_data)
1278 # We use "git apply" to apply the patch instead of "patch" so that we can
1279 # pick up file adds.
1280 # The --index flag means: also insert into the index (so we catch adds).
1281 cmd = ['git', 'apply', '--index', '-p0']
1282 if options.reject:
1283 cmd.append('--reject')
1284 patch_proc = Popen(cmd, stdin=subprocess.PIPE)
1285 patch_proc.communicate(patch_data)
1286 if patch_proc.returncode:
1287 DieWithError('Failed to apply the patch')
1288
1289 # If we had an issue, commit the current state and register the issue.
1290 if not options.nocommit:
1291 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1292 cl = Changelist()
1293 cl.SetIssue(issue)
1294 print "Committed patch."
1295 else:
1296 print "Patch applied to index."
1297 return 0
1298
1299
1300def CMDrebase(parser, args):
1301 """rebase current branch on top of svn repo"""
1302 # Provide a wrapper for git svn rebase to help avoid accidental
1303 # git svn dcommit.
1304 # It's the only command that doesn't use parser at all since we just defer
1305 # execution to git-svn.
1306 RunGit(['svn', 'rebase'] + args, redirect_stdout=False)
1307 return 0
1308
1309
1310def GetTreeStatus():
1311 """Fetches the tree status and returns either 'open', 'closed',
1312 'unknown' or 'unset'."""
1313 url = settings.GetTreeStatusUrl(error_ok=True)
1314 if url:
1315 status = urllib2.urlopen(url).read().lower()
1316 if status.find('closed') != -1 or status == '0':
1317 return 'closed'
1318 elif status.find('open') != -1 or status == '1':
1319 return 'open'
1320 return 'unknown'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001321 return 'unset'
1322
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001323
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001324def GetTreeStatusReason():
1325 """Fetches the tree status from a json url and returns the message
1326 with the reason for the tree to be opened or closed."""
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001327 url = settings.GetTreeStatusUrl()
1328 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001329 connection = urllib2.urlopen(json_url)
1330 status = json.loads(connection.read())
1331 connection.close()
1332 return status['message']
1333
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001334
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001335def CMDtree(parser, args):
1336 """show the status of the tree"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001337 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001338 status = GetTreeStatus()
1339 if 'unset' == status:
1340 print 'You must configure your tree status URL by running "git cl config".'
1341 return 2
1342
1343 print "The tree is %s" % status
1344 print
1345 print GetTreeStatusReason()
1346 if status != 'open':
1347 return 1
1348 return 0
1349
1350
1351def CMDupstream(parser, args):
1352 """print the name of the upstream branch, if any"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001353 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001354 cl = Changelist()
1355 print cl.GetUpstreamBranch()
1356 return 0
1357
1358
1359def Command(name):
1360 return getattr(sys.modules[__name__], 'CMD' + name, None)
1361
1362
1363def CMDhelp(parser, args):
1364 """print list of commands or help for a specific command"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001365 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001366 if len(args) == 1:
1367 return main(args + ['--help'])
1368 parser.print_help()
1369 return 0
1370
1371
1372def GenUsage(parser, command):
1373 """Modify an OptParse object with the function's documentation."""
1374 obj = Command(command)
1375 more = getattr(obj, 'usage_more', '')
1376 if command == 'help':
1377 command = '<command>'
1378 else:
1379 # OptParser.description prefer nicely non-formatted strings.
1380 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1381 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1382
1383
1384def main(argv):
1385 """Doesn't parse the arguments here, just find the right subcommand to
1386 execute."""
1387 # Do it late so all commands are listed.
1388 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1389 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1390 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1391
1392 # Create the option parse and add --verbose support.
1393 parser = optparse.OptionParser()
1394 parser.add_option('-v', '--verbose', action='store_true')
1395 old_parser_args = parser.parse_args
1396 def Parse(args):
1397 options, args = old_parser_args(args)
1398 if options.verbose:
1399 logging.basicConfig(level=logging.DEBUG)
1400 else:
1401 logging.basicConfig(level=logging.WARNING)
1402 return options, args
1403 parser.parse_args = Parse
1404
1405 if argv:
1406 command = Command(argv[0])
1407 if command:
1408 # "fix" the usage and the description now that we know the subcommand.
1409 GenUsage(parser, argv[0])
1410 try:
1411 return command(parser, argv[1:])
1412 except urllib2.HTTPError, e:
1413 if e.code != 500:
1414 raise
1415 DieWithError(
1416 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1417 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1418
1419 # Not a known command. Default to help.
1420 GenUsage(parser, 'help')
1421 return CMDhelp(parser, argv)
1422
1423
1424if __name__ == '__main__':
maruel@chromium.org6f09cd92011-04-01 16:38:12 +00001425 fix_encoding.fix_encoding()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001426 sys.exit(main(sys.argv[1:]))