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