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