blob: 218c9f3dae502f739951780173f5b41f56964fea [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
843 if options.upload:
844 print '*** Presubmit checks for UPLOAD would report: ***'
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000845 RunHook(committing=False, upstream_branch=base_branch,
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000846 rietveld_server=cl.GetRietveldServer(), tbr=False,
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000847 may_prompt=False)
848 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000849 else:
850 print '*** Presubmit checks for DCOMMIT would report: ***'
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000851 RunHook(committing=True, upstream_branch=base_branch,
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000852 rietveld_server=cl.GetRietveldServer, tbr=False,
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000853 may_prompt=False)
854 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000855
856
857@usage('[args to "git diff"]')
858def CMDupload(parser, args):
859 """upload the current changelist to codereview"""
860 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
861 help='bypass upload presubmit hook')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000862 parser.add_option('-f', action='store_true', dest='force',
863 help="force yes to questions (don't prompt)")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000864 parser.add_option('-m', dest='message', help='message for patch')
865 parser.add_option('-r', '--reviewers',
866 help='reviewer email addresses')
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000867 parser.add_option('--cc',
868 help='cc email addresses')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000869 parser.add_option('--send-mail', action='store_true',
870 help='send email to reviewer immediately')
871 parser.add_option("--emulate_svn_auto_props", action="store_true",
872 dest="emulate_svn_auto_props",
873 help="Emulate Subversion's auto properties feature.")
874 parser.add_option("--desc_from_logs", action="store_true",
875 dest="from_logs",
876 help="""Squashes git commit logs into change description and
877 uses message as subject""")
878 (options, args) = parser.parse_args(args)
879
880 # Make sure index is up-to-date before running diff-index.
881 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
882 if RunGit(['diff-index', 'HEAD']):
883 print 'Cannot upload with a dirty tree. You must commit locally first.'
884 return 1
885
886 cl = Changelist()
887 if args:
888 base_branch = args[0]
889 else:
890 # Default to diffing against the "upstream" branch.
891 base_branch = cl.GetUpstreamBranch()
892 args = [base_branch + "..."]
893
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000894 if not options.bypass_hooks and not options.force:
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000895 hook_results = RunHook(committing=False, upstream_branch=base_branch,
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000896 rietveld_server=cl.GetRietveldServer(), tbr=False,
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000897 may_prompt=True)
898 if not options.reviewers and hook_results.reviewers:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000899 options.reviewers = hook_results.reviewers
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000900
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000901
902 # --no-ext-diff is broken in some versions of Git, so try to work around
903 # this by overriding the environment (but there is still a problem if the
904 # git config key "diff.external" is used).
905 env = os.environ.copy()
906 if 'GIT_EXTERNAL_DIFF' in env:
907 del env['GIT_EXTERNAL_DIFF']
908 subprocess.call(['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args,
909 env=env)
910
911 upload_args = ['--assume_yes'] # Don't ask about untracked files.
912 upload_args.extend(['--server', cl.GetRietveldServer()])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000913 if options.emulate_svn_auto_props:
914 upload_args.append('--emulate_svn_auto_props')
915 if options.send_mail:
916 if not options.reviewers:
917 DieWithError("Must specify reviewers to send email.")
918 upload_args.append('--send_mail')
919 if options.from_logs and not options.message:
920 print 'Must set message for subject line if using desc_from_logs'
921 return 1
922
923 change_desc = None
924
925 if cl.GetIssue():
926 if options.message:
927 upload_args.extend(['--message', options.message])
928 upload_args.extend(['--issue', cl.GetIssue()])
929 print ("This branch is associated with issue %s. "
930 "Adding patch to that issue." % cl.GetIssue())
931 else:
932 log_desc = CreateDescriptionFromLog(args)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000933 change_desc = ChangeDescription(options.message, log_desc,
934 options.reviewers)
935 if not options.from_logs:
936 change_desc.Update()
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000937
938 if change_desc.IsEmpty():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000939 print "Description is empty; aborting."
940 return 1
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000941
942 upload_args.extend(['--message', change_desc.subject])
943 upload_args.extend(['--description', change_desc.description])
944 if change_desc.reviewers:
945 upload_args.extend(['--reviewers', change_desc.reviewers])
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000946 cc = ','.join(filter(None, (settings.GetCCList(), options.cc)))
947 if cc:
948 upload_args.extend(['--cc', cc])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000949
950 # Include the upstream repo's URL in the change -- this is useful for
951 # projects that have their source spread across multiple repos.
952 remote_url = None
953 if settings.GetIsGitSvn():
maruel@chromium.orgb92e4802011-03-03 20:22:00 +0000954 # URL is dependent on the current directory.
955 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000956 if data:
957 keys = dict(line.split(': ', 1) for line in data.splitlines()
958 if ': ' in line)
959 remote_url = keys.get('URL', None)
960 else:
961 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
962 remote_url = (cl.GetRemoteUrl() + '@'
963 + cl.GetUpstreamBranch().split('/')[-1])
964 if remote_url:
965 upload_args.extend(['--base_url', remote_url])
966
967 try:
968 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
969 except:
970 # If we got an exception after the user typed a description for their
971 # change, back up the description before re-raising.
972 if change_desc:
973 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
974 print '\nGot exception while uploading -- saving description to %s\n' \
975 % backup_path
976 backup_file = open(backup_path, 'w')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000977 backup_file.write(change_desc.description)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000978 backup_file.close()
979 raise
980
981 if not cl.GetIssue():
982 cl.SetIssue(issue)
983 cl.SetPatchset(patchset)
984 return 0
985
986
987def SendUpstream(parser, args, cmd):
988 """Common code for CmdPush and CmdDCommit
989
990 Squashed commit into a single.
991 Updates changelog with metadata (e.g. pointer to review).
992 Pushes/dcommits the code upstream.
993 Updates review and closes.
994 """
995 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
996 help='bypass upload presubmit hook')
997 parser.add_option('-m', dest='message',
998 help="override review description")
999 parser.add_option('-f', action='store_true', dest='force',
1000 help="force yes to questions (don't prompt)")
1001 parser.add_option('-c', dest='contributor',
1002 help="external contributor for patch (appended to " +
1003 "description and used as author for git). Should be " +
1004 "formatted as 'First Last <email@example.com>'")
1005 parser.add_option('--tbr', action='store_true', dest='tbr',
1006 help="short for 'to be reviewed', commit branch " +
1007 "even without uploading for review")
1008 (options, args) = parser.parse_args(args)
1009 cl = Changelist()
1010
1011 if not args or cmd == 'push':
1012 # Default to merging against our best guess of the upstream branch.
1013 args = [cl.GetUpstreamBranch()]
1014
1015 base_branch = args[0]
1016
chase@chromium.orgc76e6752011-01-10 18:17:12 +00001017 # Make sure index is up-to-date before running diff-index.
1018 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001019 if RunGit(['diff-index', 'HEAD']):
1020 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
1021 return 1
1022
1023 # This rev-list syntax means "show all commits not in my branch that
1024 # are in base_branch".
1025 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
1026 base_branch]).splitlines()
1027 if upstream_commits:
1028 print ('Base branch "%s" has %d commits '
1029 'not in this branch.' % (base_branch, len(upstream_commits)))
1030 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
1031 return 1
1032
1033 if cmd == 'dcommit':
1034 # This is the revision `svn dcommit` will commit on top of.
1035 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
1036 '--pretty=format:%H'])
1037 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
1038 if extra_commits:
1039 print ('This branch has %d additional commits not upstreamed yet.'
1040 % len(extra_commits.splitlines()))
1041 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
1042 'before attempting to %s.' % (base_branch, cmd))
1043 return 1
1044
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001045 if not options.bypass_hooks and not options.force:
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001046 RunHook(committing=True, upstream_branch=base_branch,
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001047 rietveld_server=cl.GetRietveldServer(), tbr=options.tbr,
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001048 may_prompt=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001049
1050 if cmd == 'dcommit':
1051 # Check the tree status if the tree status URL is set.
1052 status = GetTreeStatus()
1053 if 'closed' == status:
1054 print ('The tree is closed. Please wait for it to reopen. Use '
1055 '"git cl dcommit -f" to commit on a closed tree.')
1056 return 1
1057 elif 'unknown' == status:
1058 print ('Unable to determine tree status. Please verify manually and '
1059 'use "git cl dcommit -f" to commit on a closed tree.')
1060
1061 description = options.message
1062 if not options.tbr:
1063 # It is important to have these checks early. Not only for user
1064 # convenience, but also because the cl object then caches the correct values
1065 # of these fields even as we're juggling branches for setting up the commit.
1066 if not cl.GetIssue():
1067 print 'Current issue unknown -- has this branch been uploaded?'
1068 print 'Use --tbr to commit without review.'
1069 return 1
1070
1071 if not description:
1072 description = cl.GetDescription()
1073
1074 if not description:
1075 print 'No description set.'
1076 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
1077 return 1
1078
1079 description += "\n\nReview URL: %s" % cl.GetIssueURL()
1080 else:
1081 if not description:
1082 # Submitting TBR. See if there's already a description in Rietveld, else
1083 # create a template description. Eitherway, give the user a chance to edit
1084 # it to fill in the TBR= field.
1085 if cl.GetIssue():
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001086 description = cl.GetDescription()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001087
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001088 # TODO(dpranke): Update to use ChangeDescription object.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001089 if not description:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001090 description = """# Enter a description of the change.
1091# This will be used as the change log for the commit.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001092
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001093"""
1094 description += CreateDescriptionFromLog(args)
1095
1096 description = UserEditedLog(description + '\nTBR=')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001097
1098 if not description:
1099 print "Description empty; aborting."
1100 return 1
1101
1102 if options.contributor:
1103 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
1104 print "Please provide contibutor as 'First Last <email@example.com>'"
1105 return 1
1106 description += "\nPatch from %s." % options.contributor
1107 print 'Description:', repr(description)
1108
1109 branches = [base_branch, cl.GetBranchRef()]
1110 if not options.force:
1111 subprocess.call(['git', 'diff', '--stat'] + branches)
1112 raw_input("About to commit; enter to confirm.")
1113
1114 # We want to squash all this branch's commits into one commit with the
1115 # proper description.
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001116 # We do this by doing a "reset --soft" to the base branch (which keeps
1117 # the working copy the same), then dcommitting that.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001118 MERGE_BRANCH = 'git-cl-commit'
1119 # Delete the merge branch if it already exists.
1120 if RunGitWithCode(['show-ref', '--quiet', '--verify',
1121 'refs/heads/' + MERGE_BRANCH])[0] == 0:
1122 RunGit(['branch', '-D', MERGE_BRANCH])
1123
1124 # We might be in a directory that's present in this branch but not in the
1125 # trunk. Move up to the top of the tree so that git commands that expect a
1126 # valid CWD won't fail after we check out the merge branch.
1127 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
1128 if rel_base_path:
1129 os.chdir(rel_base_path)
1130
1131 # Stuff our change into the merge branch.
1132 # We wrap in a try...finally block so if anything goes wrong,
1133 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001134 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001135 try:
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001136 RunGit(['checkout', '-q', '-b', MERGE_BRANCH])
1137 RunGit(['reset', '--soft', base_branch])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001138 if options.contributor:
1139 RunGit(['commit', '--author', options.contributor, '-m', description])
1140 else:
1141 RunGit(['commit', '-m', description])
1142 if cmd == 'push':
1143 # push the merge branch.
1144 remote, branch = cl.FetchUpstreamTuple()
1145 retcode, output = RunGitWithCode(
1146 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1147 logging.debug(output)
1148 else:
1149 # dcommit the merge branch.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001150 retcode, output = RunGitWithCode(['svn', 'dcommit', '--no-rebase'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001151 finally:
1152 # And then swap back to the original branch and clean up.
1153 RunGit(['checkout', '-q', cl.GetBranch()])
1154 RunGit(['branch', '-D', MERGE_BRANCH])
1155
1156 if cl.GetIssue():
1157 if cmd == 'dcommit' and 'Committed r' in output:
1158 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1159 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001160 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1161 for l in output.splitlines(False))
1162 match = filter(None, match)
1163 if len(match) != 1:
1164 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1165 output)
1166 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001167 else:
1168 return 1
1169 viewvc_url = settings.GetViewVCUrl()
1170 if viewvc_url and revision:
1171 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1172 print ('Closing issue '
1173 '(you may be prompted for your codereview password)...')
1174 cl.CloseIssue()
1175 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001176
1177 if retcode == 0:
1178 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1179 if os.path.isfile(hook):
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001180 RunCommand([hook, base_branch], error_ok=True)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001181
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001182 return 0
1183
1184
1185@usage('[upstream branch to apply against]')
1186def CMDdcommit(parser, args):
1187 """commit the current changelist via git-svn"""
1188 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001189 message = """This doesn't appear to be an SVN repository.
1190If your project has a git mirror with an upstream SVN master, you probably need
1191to run 'git svn init', see your project's git mirror documentation.
1192If your project has a true writeable upstream repository, you probably want
1193to run 'git cl push' instead.
1194Choose wisely, if you get this wrong, your commit might appear to succeed but
1195will instead be silently ignored."""
1196 print(message)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001197 raw_input('[Press enter to dcommit or ctrl-C to quit]')
1198 return SendUpstream(parser, args, 'dcommit')
1199
1200
1201@usage('[upstream branch to apply against]')
1202def CMDpush(parser, args):
1203 """commit the current changelist via git"""
1204 if settings.GetIsGitSvn():
1205 print('This appears to be an SVN repository.')
1206 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
1207 raw_input('[Press enter to push or ctrl-C to quit]')
1208 return SendUpstream(parser, args, 'push')
1209
1210
1211@usage('<patch url or issue id>')
1212def CMDpatch(parser, args):
1213 """patch in a code review"""
1214 parser.add_option('-b', dest='newbranch',
1215 help='create a new branch off trunk for the patch')
1216 parser.add_option('-f', action='store_true', dest='force',
1217 help='with -b, clobber any existing branch')
1218 parser.add_option('--reject', action='store_true', dest='reject',
1219 help='allow failed patches and spew .rej files')
1220 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1221 help="don't commit after patch applies")
1222 (options, args) = parser.parse_args(args)
1223 if len(args) != 1:
1224 parser.print_help()
1225 return 1
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001226 issue_arg = args[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001227
dpranke@chromium.org6a2d0832011-03-18 05:28:42 +00001228 if re.match(r'\d+', issue_arg):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001229 # Input is an issue id. Figure out the URL.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001230 issue = issue_arg
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001231 server = settings.GetDefaultServerUrl()
1232 fetch = urllib2.urlopen('%s/%s' % (server, issue)).read()
1233 m = re.search(r'/download/issue[0-9]+_[0-9]+.diff', fetch)
1234 if not m:
1235 DieWithError('Must pass an issue ID or full URL for '
1236 '\'Download raw patch set\'')
1237 url = '%s%s' % (server, m.group(0).strip())
1238 else:
1239 # Assume it's a URL to the patch. Default to http.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001240 issue_url = FixUrl(issue_arg)
1241 match = re.match(r'.*?/issue(\d+)_\d+.diff', issue_url)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001242 if match:
1243 issue = match.group(1)
dpranke@chromium.org6a2d0832011-03-18 05:28:42 +00001244 url = issue_arg
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001245 else:
1246 DieWithError('Must pass an issue ID or full URL for '
1247 '\'Download raw patch set\'')
1248
1249 if options.newbranch:
1250 if options.force:
1251 RunGit(['branch', '-D', options.newbranch],
1252 swallow_stderr=True, error_ok=True)
1253 RunGit(['checkout', '-b', options.newbranch,
1254 Changelist().GetUpstreamBranch()])
1255
1256 # Switch up to the top-level directory, if necessary, in preparation for
1257 # applying the patch.
1258 top = RunGit(['rev-parse', '--show-cdup']).strip()
1259 if top:
1260 os.chdir(top)
1261
1262 patch_data = urllib2.urlopen(url).read()
1263 # Git patches have a/ at the beginning of source paths. We strip that out
1264 # with a sed script rather than the -p flag to patch so we can feed either
1265 # Git or svn-style patches into the same apply command.
1266 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
1267 sed_proc = Popen(['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'],
1268 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
1269 patch_data = sed_proc.communicate(patch_data)[0]
1270 if sed_proc.returncode:
1271 DieWithError('Git patch mungling failed.')
1272 logging.info(patch_data)
1273 # We use "git apply" to apply the patch instead of "patch" so that we can
1274 # pick up file adds.
1275 # The --index flag means: also insert into the index (so we catch adds).
1276 cmd = ['git', 'apply', '--index', '-p0']
1277 if options.reject:
1278 cmd.append('--reject')
1279 patch_proc = Popen(cmd, stdin=subprocess.PIPE)
1280 patch_proc.communicate(patch_data)
1281 if patch_proc.returncode:
1282 DieWithError('Failed to apply the patch')
1283
1284 # If we had an issue, commit the current state and register the issue.
1285 if not options.nocommit:
1286 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1287 cl = Changelist()
1288 cl.SetIssue(issue)
1289 print "Committed patch."
1290 else:
1291 print "Patch applied to index."
1292 return 0
1293
1294
1295def CMDrebase(parser, args):
1296 """rebase current branch on top of svn repo"""
1297 # Provide a wrapper for git svn rebase to help avoid accidental
1298 # git svn dcommit.
1299 # It's the only command that doesn't use parser at all since we just defer
1300 # execution to git-svn.
1301 RunGit(['svn', 'rebase'] + args, redirect_stdout=False)
1302 return 0
1303
1304
1305def GetTreeStatus():
1306 """Fetches the tree status and returns either 'open', 'closed',
1307 'unknown' or 'unset'."""
1308 url = settings.GetTreeStatusUrl(error_ok=True)
1309 if url:
1310 status = urllib2.urlopen(url).read().lower()
1311 if status.find('closed') != -1 or status == '0':
1312 return 'closed'
1313 elif status.find('open') != -1 or status == '1':
1314 return 'open'
1315 return 'unknown'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001316 return 'unset'
1317
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001318
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001319def GetTreeStatusReason():
1320 """Fetches the tree status from a json url and returns the message
1321 with the reason for the tree to be opened or closed."""
1322 # Don't import it at file level since simplejson is not installed by default
1323 # on python 2.5 and it is only used for git-cl tree which isn't often used,
1324 # forcing everyone to install simplejson isn't efficient.
1325 try:
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001326 import simplejson as json # pylint: disable=F0401
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001327 except ImportError:
1328 try:
1329 import json
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001330 except ImportError:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001331 print >> sys.stderr, 'Please install simplejson'
1332 sys.exit(1)
1333
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001334 url = settings.GetTreeStatusUrl()
1335 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001336 connection = urllib2.urlopen(json_url)
1337 status = json.loads(connection.read())
1338 connection.close()
1339 return status['message']
1340
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001341
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001342def CMDtree(parser, args):
1343 """show the status of the tree"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001344 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001345 status = GetTreeStatus()
1346 if 'unset' == status:
1347 print 'You must configure your tree status URL by running "git cl config".'
1348 return 2
1349
1350 print "The tree is %s" % status
1351 print
1352 print GetTreeStatusReason()
1353 if status != 'open':
1354 return 1
1355 return 0
1356
1357
1358def CMDupstream(parser, args):
1359 """print the name of the upstream branch, if any"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001360 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001361 cl = Changelist()
1362 print cl.GetUpstreamBranch()
1363 return 0
1364
1365
1366def Command(name):
1367 return getattr(sys.modules[__name__], 'CMD' + name, None)
1368
1369
1370def CMDhelp(parser, args):
1371 """print list of commands or help for a specific command"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001372 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001373 if len(args) == 1:
1374 return main(args + ['--help'])
1375 parser.print_help()
1376 return 0
1377
1378
1379def GenUsage(parser, command):
1380 """Modify an OptParse object with the function's documentation."""
1381 obj = Command(command)
1382 more = getattr(obj, 'usage_more', '')
1383 if command == 'help':
1384 command = '<command>'
1385 else:
1386 # OptParser.description prefer nicely non-formatted strings.
1387 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1388 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1389
1390
1391def main(argv):
1392 """Doesn't parse the arguments here, just find the right subcommand to
1393 execute."""
1394 # Do it late so all commands are listed.
1395 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1396 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1397 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1398
1399 # Create the option parse and add --verbose support.
1400 parser = optparse.OptionParser()
1401 parser.add_option('-v', '--verbose', action='store_true')
1402 old_parser_args = parser.parse_args
1403 def Parse(args):
1404 options, args = old_parser_args(args)
1405 if options.verbose:
1406 logging.basicConfig(level=logging.DEBUG)
1407 else:
1408 logging.basicConfig(level=logging.WARNING)
1409 return options, args
1410 parser.parse_args = Parse
1411
1412 if argv:
1413 command = Command(argv[0])
1414 if command:
1415 # "fix" the usage and the description now that we know the subcommand.
1416 GenUsage(parser, argv[0])
1417 try:
1418 return command(parser, argv[1:])
1419 except urllib2.HTTPError, e:
1420 if e.code != 500:
1421 raise
1422 DieWithError(
1423 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1424 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1425
1426 # Not a known command. Default to help.
1427 GenUsage(parser, 'help')
1428 return CMDhelp(parser, argv)
1429
1430
1431if __name__ == '__main__':
1432 sys.exit(main(sys.argv[1:]))