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