blob: 665e2a0c5646d10c53801c80a8e5053b0f1b2983 [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.org97ae58e2011-03-18 00:29:20 +000022# TODO(dpranke): don't use relative import.
23import upload # pylint: disable=W0403
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000024try:
25 # TODO(dpranke): We wrap this in a try block for a limited form of
26 # backwards-compatibility with older versions of git-cl that weren't
27 # dependent on depot_tools. This version should still work outside of
28 # depot_tools as long as --bypass-hooks is used. We should remove this
29 # once this has baked for a while and things seem safe.
30 depot_tools_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
31 sys.path.append(depot_tools_path)
32 import breakpad # pylint: disable=W0611
33except ImportError:
34 pass
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000035
36DEFAULT_SERVER = 'http://codereview.appspot.com'
maruel@chromium.org0ba7f962011-01-11 22:13:58 +000037POSTUPSTREAM_HOOK_PATTERN = '.git/hooks/post-cl-%s'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000038DESCRIPTION_BACKUP_FILE = '~/.git_cl_description_backup'
39
40def DieWithError(message):
dpranke@chromium.org970c5222011-03-12 00:32:24 +000041 print >> sys.stderr, message
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000042 sys.exit(1)
43
44
45def Popen(cmd, **kwargs):
46 """Wrapper for subprocess.Popen() that logs and watch for cygwin issues"""
47 logging.info('Popen: ' + ' '.join(cmd))
48 try:
49 return subprocess.Popen(cmd, **kwargs)
50 except OSError, e:
51 if e.errno == errno.EAGAIN and sys.platform == 'cygwin':
52 DieWithError(
53 'Visit '
54 'http://code.google.com/p/chromium/wiki/CygwinDllRemappingFailure to '
55 'learn how to fix this error; you need to rebase your cygwin dlls')
56 raise
57
58
59def RunCommand(cmd, error_ok=False, error_message=None,
maruel@chromium.orgb92e4802011-03-03 20:22:00 +000060 redirect_stdout=True, swallow_stderr=False, **kwargs):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000061 if redirect_stdout:
62 stdout = subprocess.PIPE
63 else:
64 stdout = None
65 if swallow_stderr:
66 stderr = subprocess.PIPE
67 else:
68 stderr = None
maruel@chromium.orgb92e4802011-03-03 20:22:00 +000069 proc = Popen(cmd, stdout=stdout, stderr=stderr, **kwargs)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000070 output = proc.communicate()[0]
71 if not error_ok and proc.returncode != 0:
72 DieWithError('Command "%s" failed.\n' % (' '.join(cmd)) +
73 (error_message or output or ''))
74 return output
75
76
77def RunGit(args, **kwargs):
78 cmd = ['git'] + args
79 return RunCommand(cmd, **kwargs)
80
81
82def RunGitWithCode(args):
83 proc = Popen(['git'] + args, stdout=subprocess.PIPE)
84 output = proc.communicate()[0]
85 return proc.returncode, output
86
87
88def usage(more):
89 def hook(fn):
90 fn.usage_more = more
91 return fn
92 return hook
93
94
95def FixUrl(server):
96 """Fix a server url to defaults protocol to http:// if none is specified."""
97 if not server:
98 return server
99 if not re.match(r'[a-z]+\://.*', server):
100 return 'http://' + server
101 return server
102
103
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000104def MatchSvnGlob(url, base_url, glob_spec, allow_wildcards):
105 """Return the corresponding git ref if |base_url| together with |glob_spec|
106 matches the full |url|.
107
108 If |allow_wildcards| is true, |glob_spec| can contain wildcards (see below).
109 """
110 fetch_suburl, as_ref = glob_spec.split(':')
111 if allow_wildcards:
112 glob_match = re.match('(.+/)?(\*|{[^/]*})(/.+)?', fetch_suburl)
113 if glob_match:
114 # Parse specs like "branches/*/src:refs/remotes/svn/*" or
115 # "branches/{472,597,648}/src:refs/remotes/svn/*".
116 branch_re = re.escape(base_url)
117 if glob_match.group(1):
118 branch_re += '/' + re.escape(glob_match.group(1))
119 wildcard = glob_match.group(2)
120 if wildcard == '*':
121 branch_re += '([^/]*)'
122 else:
123 # Escape and replace surrounding braces with parentheses and commas
124 # with pipe symbols.
125 wildcard = re.escape(wildcard)
126 wildcard = re.sub('^\\\\{', '(', wildcard)
127 wildcard = re.sub('\\\\,', '|', wildcard)
128 wildcard = re.sub('\\\\}$', ')', wildcard)
129 branch_re += wildcard
130 if glob_match.group(3):
131 branch_re += re.escape(glob_match.group(3))
132 match = re.match(branch_re, url)
133 if match:
134 return re.sub('\*$', match.group(1), as_ref)
135
136 # Parse specs like "trunk/src:refs/remotes/origin/trunk".
137 if fetch_suburl:
138 full_url = base_url + '/' + fetch_suburl
139 else:
140 full_url = base_url
141 if full_url == url:
142 return as_ref
143 return None
144
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000145class Settings(object):
146 def __init__(self):
147 self.default_server = None
148 self.cc = None
149 self.root = None
150 self.is_git_svn = None
151 self.svn_branch = None
152 self.tree_status_url = None
153 self.viewvc_url = None
154 self.updated = False
155
156 def LazyUpdateIfNeeded(self):
157 """Updates the settings from a codereview.settings file, if available."""
158 if not self.updated:
159 cr_settings_file = FindCodereviewSettingsFile()
160 if cr_settings_file:
161 LoadCodereviewSettingsFromFile(cr_settings_file)
162 self.updated = True
163
164 def GetDefaultServerUrl(self, error_ok=False):
165 if not self.default_server:
166 self.LazyUpdateIfNeeded()
167 self.default_server = FixUrl(self._GetConfig('rietveld.server',
168 error_ok=True))
169 if error_ok:
170 return self.default_server
171 if not self.default_server:
172 error_message = ('Could not find settings file. You must configure '
173 'your review setup by running "git cl config".')
174 self.default_server = FixUrl(self._GetConfig(
175 'rietveld.server', error_message=error_message))
176 return self.default_server
177
178 def GetCCList(self):
179 """Return the users cc'd on this CL.
180
181 Return is a string suitable for passing to gcl with the --cc flag.
182 """
183 if self.cc is None:
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000184 base_cc = self._GetConfig('rietveld.cc', error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000185 more_cc = self._GetConfig('rietveld.extracc', error_ok=True)
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000186 self.cc = ','.join(filter(None, (base_cc, more_cc))) or ''
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000187 return self.cc
188
189 def GetRoot(self):
190 if not self.root:
191 self.root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
192 return self.root
193
194 def GetIsGitSvn(self):
195 """Return true if this repo looks like it's using git-svn."""
196 if self.is_git_svn is None:
197 # If you have any "svn-remote.*" config keys, we think you're using svn.
198 self.is_git_svn = RunGitWithCode(
199 ['config', '--get-regexp', r'^svn-remote\.'])[0] == 0
200 return self.is_git_svn
201
202 def GetSVNBranch(self):
203 if self.svn_branch is None:
204 if not self.GetIsGitSvn():
205 DieWithError('Repo doesn\'t appear to be a git-svn repo.')
206
207 # Try to figure out which remote branch we're based on.
208 # Strategy:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000209 # 1) iterate through our branch history and find the svn URL.
210 # 2) find the svn-remote that fetches from the URL.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000211
212 # regexp matching the git-svn line that contains the URL.
213 git_svn_re = re.compile(r'^\s*git-svn-id: (\S+)@', re.MULTILINE)
214
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000215 # We don't want to go through all of history, so read a line from the
216 # pipe at a time.
217 # The -100 is an arbitrary limit so we don't search forever.
218 cmd = ['git', 'log', '-100', '--pretty=medium']
219 proc = Popen(cmd, stdout=subprocess.PIPE)
220 for line in proc.stdout:
221 match = git_svn_re.match(line)
222 if match:
223 url = match.group(1)
224 proc.stdout.close() # Cut pipe.
225 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000226
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000227 if url:
228 svn_remote_re = re.compile(r'^svn-remote\.([^.]+)\.url (.*)$')
229 remotes = RunGit(['config', '--get-regexp',
230 r'^svn-remote\..*\.url']).splitlines()
231 for remote in remotes:
232 match = svn_remote_re.match(remote)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000233 if match:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000234 remote = match.group(1)
235 base_url = match.group(2)
236 fetch_spec = RunGit(
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000237 ['config', 'svn-remote.%s.fetch' % remote],
238 error_ok=True).strip()
239 if fetch_spec:
240 self.svn_branch = MatchSvnGlob(url, base_url, fetch_spec, False)
241 if self.svn_branch:
242 break
243 branch_spec = RunGit(
244 ['config', 'svn-remote.%s.branches' % remote],
245 error_ok=True).strip()
246 if branch_spec:
247 self.svn_branch = MatchSvnGlob(url, base_url, branch_spec, True)
248 if self.svn_branch:
249 break
250 tag_spec = RunGit(
251 ['config', 'svn-remote.%s.tags' % remote],
252 error_ok=True).strip()
253 if tag_spec:
254 self.svn_branch = MatchSvnGlob(url, base_url, tag_spec, True)
255 if self.svn_branch:
256 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000257
258 if not self.svn_branch:
259 DieWithError('Can\'t guess svn branch -- try specifying it on the '
260 'command line')
261
262 return self.svn_branch
263
264 def GetTreeStatusUrl(self, error_ok=False):
265 if not self.tree_status_url:
266 error_message = ('You must configure your tree status URL by running '
267 '"git cl config".')
268 self.tree_status_url = self._GetConfig('rietveld.tree-status-url',
269 error_ok=error_ok,
270 error_message=error_message)
271 return self.tree_status_url
272
273 def GetViewVCUrl(self):
274 if not self.viewvc_url:
275 self.viewvc_url = self._GetConfig('rietveld.viewvc-url', error_ok=True)
276 return self.viewvc_url
277
278 def _GetConfig(self, param, **kwargs):
279 self.LazyUpdateIfNeeded()
280 return RunGit(['config', param], **kwargs).strip()
281
282
283settings = Settings()
284
285
286did_migrate_check = False
287def CheckForMigration():
288 """Migrate from the old issue format, if found.
289
290 We used to store the branch<->issue mapping in a file in .git, but it's
291 better to store it in the .git/config, since deleting a branch deletes that
292 branch's entry there.
293 """
294
295 # Don't run more than once.
296 global did_migrate_check
297 if did_migrate_check:
298 return
299
300 gitdir = RunGit(['rev-parse', '--git-dir']).strip()
301 storepath = os.path.join(gitdir, 'cl-mapping')
302 if os.path.exists(storepath):
303 print "old-style git-cl mapping file (%s) found; migrating." % storepath
304 store = open(storepath, 'r')
305 for line in store:
306 branch, issue = line.strip().split()
307 RunGit(['config', 'branch.%s.rietveldissue' % ShortBranchName(branch),
308 issue])
309 store.close()
310 os.remove(storepath)
311 did_migrate_check = True
312
313
314def ShortBranchName(branch):
315 """Convert a name like 'refs/heads/foo' to just 'foo'."""
316 return branch.replace('refs/heads/', '')
317
318
319class Changelist(object):
320 def __init__(self, branchref=None):
321 # Poke settings so we get the "configure your server" message if necessary.
322 settings.GetDefaultServerUrl()
323 self.branchref = branchref
324 if self.branchref:
325 self.branch = ShortBranchName(self.branchref)
326 else:
327 self.branch = None
328 self.rietveld_server = None
329 self.upstream_branch = None
330 self.has_issue = False
331 self.issue = None
332 self.has_description = False
333 self.description = None
334 self.has_patchset = False
335 self.patchset = None
336
337 def GetBranch(self):
338 """Returns the short branch name, e.g. 'master'."""
339 if not self.branch:
340 self.branchref = RunGit(['symbolic-ref', 'HEAD']).strip()
341 self.branch = ShortBranchName(self.branchref)
342 return self.branch
343
344 def GetBranchRef(self):
345 """Returns the full branch name, e.g. 'refs/heads/master'."""
346 self.GetBranch() # Poke the lazy loader.
347 return self.branchref
348
349 def FetchUpstreamTuple(self):
350 """Returns a tuple containg remote and remote ref,
351 e.g. 'origin', 'refs/heads/master'
352 """
353 remote = '.'
354 branch = self.GetBranch()
355 upstream_branch = RunGit(['config', 'branch.%s.merge' % branch],
356 error_ok=True).strip()
357 if upstream_branch:
358 remote = RunGit(['config', 'branch.%s.remote' % branch]).strip()
359 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000360 upstream_branch = RunGit(['config', 'rietveld.upstream-branch'],
361 error_ok=True).strip()
362 if upstream_branch:
363 remote = RunGit(['config', 'rietveld.upstream-remote']).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000364 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000365 # Fall back on trying a git-svn upstream branch.
366 if settings.GetIsGitSvn():
367 upstream_branch = settings.GetSVNBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000368 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000369 # Else, try to guess the origin remote.
370 remote_branches = RunGit(['branch', '-r']).split()
371 if 'origin/master' in remote_branches:
372 # Fall back on origin/master if it exits.
373 remote = 'origin'
374 upstream_branch = 'refs/heads/master'
375 elif 'origin/trunk' in remote_branches:
376 # Fall back on origin/trunk if it exists. Generally a shared
377 # git-svn clone
378 remote = 'origin'
379 upstream_branch = 'refs/heads/trunk'
380 else:
381 DieWithError("""Unable to determine default branch to diff against.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000382Either pass complete "git diff"-style arguments, like
383 git cl upload origin/master
384or verify this branch is set up to track another (via the --track argument to
385"git checkout -b ...").""")
386
387 return remote, upstream_branch
388
389 def GetUpstreamBranch(self):
390 if self.upstream_branch is None:
391 remote, upstream_branch = self.FetchUpstreamTuple()
392 if remote is not '.':
393 upstream_branch = upstream_branch.replace('heads', 'remotes/' + remote)
394 self.upstream_branch = upstream_branch
395 return self.upstream_branch
396
397 def GetRemoteUrl(self):
398 """Return the configured remote URL, e.g. 'git://example.org/foo.git/'.
399
400 Returns None if there is no remote.
401 """
402 remote = self.FetchUpstreamTuple()[0]
403 if remote == '.':
404 return None
405 return RunGit(['config', 'remote.%s.url' % remote], error_ok=True).strip()
406
407 def GetIssue(self):
408 if not self.has_issue:
409 CheckForMigration()
410 issue = RunGit(['config', self._IssueSetting()], error_ok=True).strip()
411 if issue:
412 self.issue = issue
413 self.rietveld_server = FixUrl(RunGit(
414 ['config', self._RietveldServer()], error_ok=True).strip())
415 else:
416 self.issue = None
417 if not self.rietveld_server:
418 self.rietveld_server = settings.GetDefaultServerUrl()
419 self.has_issue = True
420 return self.issue
421
422 def GetRietveldServer(self):
423 self.GetIssue()
424 return self.rietveld_server
425
426 def GetIssueURL(self):
427 """Get the URL for a particular issue."""
428 return '%s/%s' % (self.GetRietveldServer(), self.GetIssue())
429
430 def GetDescription(self, pretty=False):
431 if not self.has_description:
432 if self.GetIssue():
433 path = '/' + self.GetIssue() + '/description'
434 rpc_server = self._RpcServer()
435 self.description = rpc_server.Send(path).strip()
436 self.has_description = True
437 if pretty:
438 wrapper = textwrap.TextWrapper()
439 wrapper.initial_indent = wrapper.subsequent_indent = ' '
440 return wrapper.fill(self.description)
441 return self.description
442
443 def GetPatchset(self):
444 if not self.has_patchset:
445 patchset = RunGit(['config', self._PatchsetSetting()],
446 error_ok=True).strip()
447 if patchset:
448 self.patchset = patchset
449 else:
450 self.patchset = None
451 self.has_patchset = True
452 return self.patchset
453
454 def SetPatchset(self, patchset):
455 """Set this branch's patchset. If patchset=0, clears the patchset."""
456 if patchset:
457 RunGit(['config', self._PatchsetSetting(), str(patchset)])
458 else:
459 RunGit(['config', '--unset', self._PatchsetSetting()],
460 swallow_stderr=True, error_ok=True)
461 self.has_patchset = False
462
463 def SetIssue(self, issue):
464 """Set this branch's issue. If issue=0, clears the issue."""
465 if issue:
466 RunGit(['config', self._IssueSetting(), str(issue)])
467 if self.rietveld_server:
468 RunGit(['config', self._RietveldServer(), self.rietveld_server])
469 else:
470 RunGit(['config', '--unset', self._IssueSetting()])
471 self.SetPatchset(0)
472 self.has_issue = False
473
474 def CloseIssue(self):
475 rpc_server = self._RpcServer()
476 # Newer versions of Rietveld require us to pass an XSRF token to POST, so
477 # we fetch it from the server. (The version used by Chromium has been
478 # modified so the token isn't required when closing an issue.)
479 xsrf_token = rpc_server.Send('/xsrf_token',
480 extra_headers={'X-Requesting-XSRF-Token': '1'})
481
482 # You cannot close an issue with a GET.
483 # We pass an empty string for the data so it is a POST rather than a GET.
484 data = [("description", self.description),
485 ("xsrf_token", xsrf_token)]
486 ctype, body = upload.EncodeMultipartFormData(data, [])
487 rpc_server.Send('/' + self.GetIssue() + '/close', body, ctype)
488
489 def _RpcServer(self):
490 """Returns an upload.RpcServer() to access this review's rietveld instance.
491 """
492 server = self.GetRietveldServer()
493 return upload.GetRpcServer(server, save_cookies=True)
494
495 def _IssueSetting(self):
496 """Return the git setting that stores this change's issue."""
497 return 'branch.%s.rietveldissue' % self.GetBranch()
498
499 def _PatchsetSetting(self):
500 """Return the git setting that stores this change's most recent patchset."""
501 return 'branch.%s.rietveldpatchset' % self.GetBranch()
502
503 def _RietveldServer(self):
504 """Returns the git setting that stores this change's rietveld server."""
505 return 'branch.%s.rietveldserver' % self.GetBranch()
506
507
508def GetCodereviewSettingsInteractively():
509 """Prompt the user for settings."""
510 server = settings.GetDefaultServerUrl(error_ok=True)
511 prompt = 'Rietveld server (host[:port])'
512 prompt += ' [%s]' % (server or DEFAULT_SERVER)
513 newserver = raw_input(prompt + ': ')
514 if not server and not newserver:
515 newserver = DEFAULT_SERVER
516 if newserver and newserver != server:
517 RunGit(['config', 'rietveld.server', newserver])
518
519 def SetProperty(initial, caption, name):
520 prompt = caption
521 if initial:
522 prompt += ' ("x" to clear) [%s]' % initial
523 new_val = raw_input(prompt + ': ')
524 if new_val == 'x':
525 RunGit(['config', '--unset-all', 'rietveld.' + name], error_ok=True)
526 elif new_val and new_val != initial:
527 RunGit(['config', 'rietveld.' + name, new_val])
528
529 SetProperty(settings.GetCCList(), 'CC list', 'cc')
530 SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL',
531 'tree-status-url')
532 SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url')
533
534 # TODO: configure a default branch to diff against, rather than this
535 # svn-based hackery.
536
537
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000538class ChangeDescription(object):
539 """Contains a parsed form of the change description."""
540 def __init__(self, subject, log_desc, reviewers):
541 self.subject = subject
542 self.log_desc = log_desc
543 self.reviewers = reviewers
544 self.description = self.log_desc
545
546 def Update(self):
547 initial_text = """# Enter a description of the change.
548# This will displayed on the codereview site.
549# The first line will also be used as the subject of the review.
550"""
551 initial_text += self.description
552 if 'R=' not in self.description and self.reviewers:
553 initial_text += '\nR=' + self.reviewers
554 if 'BUG=' not in self.description:
555 initial_text += '\nBUG='
556 if 'TEST=' not in self.description:
557 initial_text += '\nTEST='
558 self._ParseDescription(UserEditedLog(initial_text))
559
560 def _ParseDescription(self, description):
561 if not description:
562 self.description = description
563 return
564
565 parsed_lines = []
566 reviewers_regexp = re.compile('\s*R=(.+)')
567 reviewers = ''
568 subject = ''
569 for l in description.splitlines():
570 if not subject:
571 subject = l
572 matched_reviewers = reviewers_regexp.match(l)
573 if matched_reviewers:
574 reviewers = matched_reviewers.group(1)
575 parsed_lines.append(l)
576
577 self.description = '\n'.join(parsed_lines) + '\n'
578 self.subject = subject
579 self.reviewers = reviewers
580
581 def IsEmpty(self):
582 return not self.description
583
584
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000585def FindCodereviewSettingsFile(filename='codereview.settings'):
586 """Finds the given file starting in the cwd and going up.
587
588 Only looks up to the top of the repository unless an
589 'inherit-review-settings-ok' file exists in the root of the repository.
590 """
591 inherit_ok_file = 'inherit-review-settings-ok'
592 cwd = os.getcwd()
593 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
594 if os.path.isfile(os.path.join(root, inherit_ok_file)):
595 root = '/'
596 while True:
597 if filename in os.listdir(cwd):
598 if os.path.isfile(os.path.join(cwd, filename)):
599 return open(os.path.join(cwd, filename))
600 if cwd == root:
601 break
602 cwd = os.path.dirname(cwd)
603
604
605def LoadCodereviewSettingsFromFile(fileobj):
606 """Parse a codereview.settings file and updates hooks."""
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000607 keyvals = {}
608 for line in fileobj.read().splitlines():
609 if not line or line.startswith("#"):
610 continue
611 k, v = line.split(": ", 1)
612 keyvals[k] = v
613
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000614 def SetProperty(name, setting, unset_error_ok=False):
615 fullname = 'rietveld.' + name
616 if setting in keyvals:
617 RunGit(['config', fullname, keyvals[setting]])
618 else:
619 RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
620
621 SetProperty('server', 'CODE_REVIEW_SERVER')
622 # Only server setting is required. Other settings can be absent.
623 # In that case, we ignore errors raised during option deletion attempt.
624 SetProperty('cc', 'CC_LIST', unset_error_ok=True)
625 SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
626 SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
627
628 if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
629 #should be of the form
630 #PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
631 #ORIGIN_URL_CONFIG: http://src.chromium.org/git
632 RunGit(['config', keyvals['PUSH_URL_CONFIG'],
633 keyvals['ORIGIN_URL_CONFIG']])
634
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000635
636@usage('[repo root containing codereview.settings]')
637def CMDconfig(parser, args):
638 """edit configuration for this tree"""
639
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000640 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000641 if len(args) == 0:
642 GetCodereviewSettingsInteractively()
643 return 0
644
645 url = args[0]
646 if not url.endswith('codereview.settings'):
647 url = os.path.join(url, 'codereview.settings')
648
649 # Load code review settings and download hooks (if available).
650 LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
651 return 0
652
653
654def CMDstatus(parser, args):
655 """show status of changelists"""
656 parser.add_option('--field',
657 help='print only specific field (desc|id|patch|url)')
658 (options, args) = parser.parse_args(args)
659
660 # TODO: maybe make show_branches a flag if necessary.
661 show_branches = not options.field
662
663 if show_branches:
664 branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
665 if branches:
666 print 'Branches associated with reviews:'
667 for branch in sorted(branches.splitlines()):
668 cl = Changelist(branchref=branch)
669 print " %10s: %s" % (cl.GetBranch(), cl.GetIssue())
670
671 cl = Changelist()
672 if options.field:
673 if options.field.startswith('desc'):
674 print cl.GetDescription()
675 elif options.field == 'id':
676 issueid = cl.GetIssue()
677 if issueid:
678 print issueid
679 elif options.field == 'patch':
680 patchset = cl.GetPatchset()
681 if patchset:
682 print patchset
683 elif options.field == 'url':
684 url = cl.GetIssueURL()
685 if url:
686 print url
687 else:
688 print
689 print 'Current branch:',
690 if not cl.GetIssue():
691 print 'no issue assigned.'
692 return 0
693 print cl.GetBranch()
694 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
695 print 'Issue description:'
696 print cl.GetDescription(pretty=True)
697 return 0
698
699
700@usage('[issue_number]')
701def CMDissue(parser, args):
702 """Set or display the current code review issue number.
703
704 Pass issue number 0 to clear the current issue.
705"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000706 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000707
708 cl = Changelist()
709 if len(args) > 0:
710 try:
711 issue = int(args[0])
712 except ValueError:
713 DieWithError('Pass a number to set the issue or none to list it.\n'
714 'Maybe you want to run git cl status?')
715 cl.SetIssue(issue)
716 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
717 return 0
718
719
720def CreateDescriptionFromLog(args):
721 """Pulls out the commit log to use as a base for the CL description."""
722 log_args = []
723 if len(args) == 1 and not args[0].endswith('.'):
724 log_args = [args[0] + '..']
725 elif len(args) == 1 and args[0].endswith('...'):
726 log_args = [args[0][:-1]]
727 elif len(args) == 2:
728 log_args = [args[0] + '..' + args[1]]
729 else:
730 log_args = args[:] # Hope for the best!
731 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
732
733
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000734def UserEditedLog(starting_text):
735 """Given some starting text, let the user edit it and return the result."""
736 editor = os.getenv('EDITOR', 'vi')
737
738 (file_handle, filename) = tempfile.mkstemp()
739 fileobj = os.fdopen(file_handle, 'w')
740 fileobj.write(starting_text)
741 fileobj.close()
742
743 # Open up the default editor in the system to get the CL description.
744 try:
745 cmd = '%s %s' % (editor, filename)
746 if sys.platform == 'win32' and os.environ.get('TERM') == 'msys':
747 # Msysgit requires the usage of 'env' to be present.
748 cmd = 'env ' + cmd
749 # shell=True to allow the shell to handle all forms of quotes in $EDITOR.
750 subprocess.check_call(cmd, shell=True)
751 fileobj = open(filename)
752 text = fileobj.read()
753 fileobj.close()
754 finally:
755 os.remove(filename)
756
757 if not text:
758 return
759
760 stripcomment_re = re.compile(r'^#.*$', re.MULTILINE)
761 return stripcomment_re.sub('', text).strip()
762
763
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000764def ConvertToInteger(inputval):
765 """Convert a string to integer, but returns either an int or None."""
766 try:
767 return int(inputval)
768 except (TypeError, ValueError):
769 return None
770
771
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000772def RunHook(committing, upstream_branch, rietveld_server, tbr, may_prompt):
773 """Calls sys.exit() if the hook fails; returns a HookResults otherwise."""
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000774 import presubmit_support
775 import scm
776 import watchlists
777
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."""
1314 # Don't import it at file level since simplejson is not installed by default
1315 # on python 2.5 and it is only used for git-cl tree which isn't often used,
1316 # forcing everyone to install simplejson isn't efficient.
1317 try:
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001318 import simplejson as json # pylint: disable=F0401
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001319 except ImportError:
1320 try:
1321 import json
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001322 except ImportError:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001323 print >> sys.stderr, 'Please install simplejson'
1324 sys.exit(1)
1325
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001326 url = settings.GetTreeStatusUrl()
1327 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001328 connection = urllib2.urlopen(json_url)
1329 status = json.loads(connection.read())
1330 connection.close()
1331 return status['message']
1332
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001333
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001334def CMDtree(parser, args):
1335 """show the status of the tree"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001336 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001337 status = GetTreeStatus()
1338 if 'unset' == status:
1339 print 'You must configure your tree status URL by running "git cl config".'
1340 return 2
1341
1342 print "The tree is %s" % status
1343 print
1344 print GetTreeStatusReason()
1345 if status != 'open':
1346 return 1
1347 return 0
1348
1349
1350def CMDupstream(parser, args):
1351 """print the name of the upstream branch, if any"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001352 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001353 cl = Changelist()
1354 print cl.GetUpstreamBranch()
1355 return 0
1356
1357
1358def Command(name):
1359 return getattr(sys.modules[__name__], 'CMD' + name, None)
1360
1361
1362def CMDhelp(parser, args):
1363 """print list of commands or help for a specific command"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001364 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001365 if len(args) == 1:
1366 return main(args + ['--help'])
1367 parser.print_help()
1368 return 0
1369
1370
1371def GenUsage(parser, command):
1372 """Modify an OptParse object with the function's documentation."""
1373 obj = Command(command)
1374 more = getattr(obj, 'usage_more', '')
1375 if command == 'help':
1376 command = '<command>'
1377 else:
1378 # OptParser.description prefer nicely non-formatted strings.
1379 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1380 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1381
1382
1383def main(argv):
1384 """Doesn't parse the arguments here, just find the right subcommand to
1385 execute."""
1386 # Do it late so all commands are listed.
1387 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1388 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1389 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1390
1391 # Create the option parse and add --verbose support.
1392 parser = optparse.OptionParser()
1393 parser.add_option('-v', '--verbose', action='store_true')
1394 old_parser_args = parser.parse_args
1395 def Parse(args):
1396 options, args = old_parser_args(args)
1397 if options.verbose:
1398 logging.basicConfig(level=logging.DEBUG)
1399 else:
1400 logging.basicConfig(level=logging.WARNING)
1401 return options, args
1402 parser.parse_args = Parse
1403
1404 if argv:
1405 command = Command(argv[0])
1406 if command:
1407 # "fix" the usage and the description now that we know the subcommand.
1408 GenUsage(parser, argv[0])
1409 try:
1410 return command(parser, argv[1:])
1411 except urllib2.HTTPError, e:
1412 if e.code != 500:
1413 raise
1414 DieWithError(
1415 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1416 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1417
1418 # Not a known command. Default to help.
1419 GenUsage(parser, 'help')
1420 return CMDhelp(parser, argv)
1421
1422
1423if __name__ == '__main__':
1424 sys.exit(main(sys.argv[1:]))