blob: 2005c19cd6ef11fe3098129d59006d7483bd17c2 [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
12import tempfile
13import 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
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000024try:
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +000025 # 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.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000030 depot_tools_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
31 sys.path.append(depot_tools_path)
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +000032 import breakpad # pylint: disable=W0611
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000033except ImportError:
34 pass
35
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
104class Settings(object):
105 def __init__(self):
106 self.default_server = None
107 self.cc = None
108 self.root = None
109 self.is_git_svn = None
110 self.svn_branch = None
111 self.tree_status_url = None
112 self.viewvc_url = None
113 self.updated = False
114
115 def LazyUpdateIfNeeded(self):
116 """Updates the settings from a codereview.settings file, if available."""
117 if not self.updated:
118 cr_settings_file = FindCodereviewSettingsFile()
119 if cr_settings_file:
120 LoadCodereviewSettingsFromFile(cr_settings_file)
121 self.updated = True
122
123 def GetDefaultServerUrl(self, error_ok=False):
124 if not self.default_server:
125 self.LazyUpdateIfNeeded()
126 self.default_server = FixUrl(self._GetConfig('rietveld.server',
127 error_ok=True))
128 if error_ok:
129 return self.default_server
130 if not self.default_server:
131 error_message = ('Could not find settings file. You must configure '
132 'your review setup by running "git cl config".')
133 self.default_server = FixUrl(self._GetConfig(
134 'rietveld.server', error_message=error_message))
135 return self.default_server
136
137 def GetCCList(self):
138 """Return the users cc'd on this CL.
139
140 Return is a string suitable for passing to gcl with the --cc flag.
141 """
142 if self.cc is None:
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000143 base_cc = self._GetConfig('rietveld.cc', error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000144 more_cc = self._GetConfig('rietveld.extracc', error_ok=True)
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000145 self.cc = ','.join(filter(None, (base_cc, more_cc))) or ''
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000146 return self.cc
147
148 def GetRoot(self):
149 if not self.root:
150 self.root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
151 return self.root
152
153 def GetIsGitSvn(self):
154 """Return true if this repo looks like it's using git-svn."""
155 if self.is_git_svn is None:
156 # If you have any "svn-remote.*" config keys, we think you're using svn.
157 self.is_git_svn = RunGitWithCode(
158 ['config', '--get-regexp', r'^svn-remote\.'])[0] == 0
159 return self.is_git_svn
160
161 def GetSVNBranch(self):
162 if self.svn_branch is None:
163 if not self.GetIsGitSvn():
164 DieWithError('Repo doesn\'t appear to be a git-svn repo.')
165
166 # Try to figure out which remote branch we're based on.
167 # Strategy:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000168 # 1) iterate through our branch history and find the svn URL.
169 # 2) find the svn-remote that fetches from the URL.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000170
171 # regexp matching the git-svn line that contains the URL.
172 git_svn_re = re.compile(r'^\s*git-svn-id: (\S+)@', re.MULTILINE)
173
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000174 # We don't want to go through all of history, so read a line from the
175 # pipe at a time.
176 # The -100 is an arbitrary limit so we don't search forever.
177 cmd = ['git', 'log', '-100', '--pretty=medium']
178 proc = Popen(cmd, stdout=subprocess.PIPE)
179 for line in proc.stdout:
180 match = git_svn_re.match(line)
181 if match:
182 url = match.group(1)
183 proc.stdout.close() # Cut pipe.
184 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000185
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000186 if url:
187 svn_remote_re = re.compile(r'^svn-remote\.([^.]+)\.url (.*)$')
188 remotes = RunGit(['config', '--get-regexp',
189 r'^svn-remote\..*\.url']).splitlines()
190 for remote in remotes:
191 match = svn_remote_re.match(remote)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000192 if match:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000193 remote = match.group(1)
194 base_url = match.group(2)
195 fetch_spec = RunGit(
196 ['config', 'svn-remote.'+remote+'.fetch']).strip().split(':')
197 if fetch_spec[0]:
198 full_url = base_url + '/' + fetch_spec[0]
199 else:
200 full_url = base_url
201 if full_url == url:
202 self.svn_branch = fetch_spec[1]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000203 break
204
205 if not self.svn_branch:
206 DieWithError('Can\'t guess svn branch -- try specifying it on the '
207 'command line')
208
209 return self.svn_branch
210
211 def GetTreeStatusUrl(self, error_ok=False):
212 if not self.tree_status_url:
213 error_message = ('You must configure your tree status URL by running '
214 '"git cl config".')
215 self.tree_status_url = self._GetConfig('rietveld.tree-status-url',
216 error_ok=error_ok,
217 error_message=error_message)
218 return self.tree_status_url
219
220 def GetViewVCUrl(self):
221 if not self.viewvc_url:
222 self.viewvc_url = self._GetConfig('rietveld.viewvc-url', error_ok=True)
223 return self.viewvc_url
224
225 def _GetConfig(self, param, **kwargs):
226 self.LazyUpdateIfNeeded()
227 return RunGit(['config', param], **kwargs).strip()
228
229
230settings = Settings()
231
232
233did_migrate_check = False
234def CheckForMigration():
235 """Migrate from the old issue format, if found.
236
237 We used to store the branch<->issue mapping in a file in .git, but it's
238 better to store it in the .git/config, since deleting a branch deletes that
239 branch's entry there.
240 """
241
242 # Don't run more than once.
243 global did_migrate_check
244 if did_migrate_check:
245 return
246
247 gitdir = RunGit(['rev-parse', '--git-dir']).strip()
248 storepath = os.path.join(gitdir, 'cl-mapping')
249 if os.path.exists(storepath):
250 print "old-style git-cl mapping file (%s) found; migrating." % storepath
251 store = open(storepath, 'r')
252 for line in store:
253 branch, issue = line.strip().split()
254 RunGit(['config', 'branch.%s.rietveldissue' % ShortBranchName(branch),
255 issue])
256 store.close()
257 os.remove(storepath)
258 did_migrate_check = True
259
260
261def ShortBranchName(branch):
262 """Convert a name like 'refs/heads/foo' to just 'foo'."""
263 return branch.replace('refs/heads/', '')
264
265
266class Changelist(object):
267 def __init__(self, branchref=None):
268 # Poke settings so we get the "configure your server" message if necessary.
269 settings.GetDefaultServerUrl()
270 self.branchref = branchref
271 if self.branchref:
272 self.branch = ShortBranchName(self.branchref)
273 else:
274 self.branch = None
275 self.rietveld_server = None
276 self.upstream_branch = None
277 self.has_issue = False
278 self.issue = None
279 self.has_description = False
280 self.description = None
281 self.has_patchset = False
282 self.patchset = None
283
284 def GetBranch(self):
285 """Returns the short branch name, e.g. 'master'."""
286 if not self.branch:
287 self.branchref = RunGit(['symbolic-ref', 'HEAD']).strip()
288 self.branch = ShortBranchName(self.branchref)
289 return self.branch
290
291 def GetBranchRef(self):
292 """Returns the full branch name, e.g. 'refs/heads/master'."""
293 self.GetBranch() # Poke the lazy loader.
294 return self.branchref
295
296 def FetchUpstreamTuple(self):
297 """Returns a tuple containg remote and remote ref,
298 e.g. 'origin', 'refs/heads/master'
299 """
300 remote = '.'
301 branch = self.GetBranch()
302 upstream_branch = RunGit(['config', 'branch.%s.merge' % branch],
303 error_ok=True).strip()
304 if upstream_branch:
305 remote = RunGit(['config', 'branch.%s.remote' % branch]).strip()
306 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000307 upstream_branch = RunGit(['config', 'rietveld.upstream-branch'],
308 error_ok=True).strip()
309 if upstream_branch:
310 remote = RunGit(['config', 'rietveld.upstream-remote']).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000311 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000312 # Fall back on trying a git-svn upstream branch.
313 if settings.GetIsGitSvn():
314 upstream_branch = settings.GetSVNBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000315 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000316 # Else, try to guess the origin remote.
317 remote_branches = RunGit(['branch', '-r']).split()
318 if 'origin/master' in remote_branches:
319 # Fall back on origin/master if it exits.
320 remote = 'origin'
321 upstream_branch = 'refs/heads/master'
322 elif 'origin/trunk' in remote_branches:
323 # Fall back on origin/trunk if it exists. Generally a shared
324 # git-svn clone
325 remote = 'origin'
326 upstream_branch = 'refs/heads/trunk'
327 else:
328 DieWithError("""Unable to determine default branch to diff against.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000329Either pass complete "git diff"-style arguments, like
330 git cl upload origin/master
331or verify this branch is set up to track another (via the --track argument to
332"git checkout -b ...").""")
333
334 return remote, upstream_branch
335
336 def GetUpstreamBranch(self):
337 if self.upstream_branch is None:
338 remote, upstream_branch = self.FetchUpstreamTuple()
339 if remote is not '.':
340 upstream_branch = upstream_branch.replace('heads', 'remotes/' + remote)
341 self.upstream_branch = upstream_branch
342 return self.upstream_branch
343
344 def GetRemoteUrl(self):
345 """Return the configured remote URL, e.g. 'git://example.org/foo.git/'.
346
347 Returns None if there is no remote.
348 """
349 remote = self.FetchUpstreamTuple()[0]
350 if remote == '.':
351 return None
352 return RunGit(['config', 'remote.%s.url' % remote], error_ok=True).strip()
353
354 def GetIssue(self):
355 if not self.has_issue:
356 CheckForMigration()
357 issue = RunGit(['config', self._IssueSetting()], error_ok=True).strip()
358 if issue:
359 self.issue = issue
360 self.rietveld_server = FixUrl(RunGit(
361 ['config', self._RietveldServer()], error_ok=True).strip())
362 else:
363 self.issue = None
364 if not self.rietveld_server:
365 self.rietveld_server = settings.GetDefaultServerUrl()
366 self.has_issue = True
367 return self.issue
368
369 def GetRietveldServer(self):
370 self.GetIssue()
371 return self.rietveld_server
372
373 def GetIssueURL(self):
374 """Get the URL for a particular issue."""
375 return '%s/%s' % (self.GetRietveldServer(), self.GetIssue())
376
377 def GetDescription(self, pretty=False):
378 if not self.has_description:
379 if self.GetIssue():
380 path = '/' + self.GetIssue() + '/description'
381 rpc_server = self._RpcServer()
382 self.description = rpc_server.Send(path).strip()
383 self.has_description = True
384 if pretty:
385 wrapper = textwrap.TextWrapper()
386 wrapper.initial_indent = wrapper.subsequent_indent = ' '
387 return wrapper.fill(self.description)
388 return self.description
389
390 def GetPatchset(self):
391 if not self.has_patchset:
392 patchset = RunGit(['config', self._PatchsetSetting()],
393 error_ok=True).strip()
394 if patchset:
395 self.patchset = patchset
396 else:
397 self.patchset = None
398 self.has_patchset = True
399 return self.patchset
400
401 def SetPatchset(self, patchset):
402 """Set this branch's patchset. If patchset=0, clears the patchset."""
403 if patchset:
404 RunGit(['config', self._PatchsetSetting(), str(patchset)])
405 else:
406 RunGit(['config', '--unset', self._PatchsetSetting()],
407 swallow_stderr=True, error_ok=True)
408 self.has_patchset = False
409
410 def SetIssue(self, issue):
411 """Set this branch's issue. If issue=0, clears the issue."""
412 if issue:
413 RunGit(['config', self._IssueSetting(), str(issue)])
414 if self.rietveld_server:
415 RunGit(['config', self._RietveldServer(), self.rietveld_server])
416 else:
417 RunGit(['config', '--unset', self._IssueSetting()])
418 self.SetPatchset(0)
419 self.has_issue = False
420
421 def CloseIssue(self):
422 rpc_server = self._RpcServer()
423 # Newer versions of Rietveld require us to pass an XSRF token to POST, so
424 # we fetch it from the server. (The version used by Chromium has been
425 # modified so the token isn't required when closing an issue.)
426 xsrf_token = rpc_server.Send('/xsrf_token',
427 extra_headers={'X-Requesting-XSRF-Token': '1'})
428
429 # You cannot close an issue with a GET.
430 # We pass an empty string for the data so it is a POST rather than a GET.
431 data = [("description", self.description),
432 ("xsrf_token", xsrf_token)]
433 ctype, body = upload.EncodeMultipartFormData(data, [])
434 rpc_server.Send('/' + self.GetIssue() + '/close', body, ctype)
435
436 def _RpcServer(self):
437 """Returns an upload.RpcServer() to access this review's rietveld instance.
438 """
439 server = self.GetRietveldServer()
440 return upload.GetRpcServer(server, save_cookies=True)
441
442 def _IssueSetting(self):
443 """Return the git setting that stores this change's issue."""
444 return 'branch.%s.rietveldissue' % self.GetBranch()
445
446 def _PatchsetSetting(self):
447 """Return the git setting that stores this change's most recent patchset."""
448 return 'branch.%s.rietveldpatchset' % self.GetBranch()
449
450 def _RietveldServer(self):
451 """Returns the git setting that stores this change's rietveld server."""
452 return 'branch.%s.rietveldserver' % self.GetBranch()
453
454
455def GetCodereviewSettingsInteractively():
456 """Prompt the user for settings."""
457 server = settings.GetDefaultServerUrl(error_ok=True)
458 prompt = 'Rietveld server (host[:port])'
459 prompt += ' [%s]' % (server or DEFAULT_SERVER)
460 newserver = raw_input(prompt + ': ')
461 if not server and not newserver:
462 newserver = DEFAULT_SERVER
463 if newserver and newserver != server:
464 RunGit(['config', 'rietveld.server', newserver])
465
466 def SetProperty(initial, caption, name):
467 prompt = caption
468 if initial:
469 prompt += ' ("x" to clear) [%s]' % initial
470 new_val = raw_input(prompt + ': ')
471 if new_val == 'x':
472 RunGit(['config', '--unset-all', 'rietveld.' + name], error_ok=True)
473 elif new_val and new_val != initial:
474 RunGit(['config', 'rietveld.' + name, new_val])
475
476 SetProperty(settings.GetCCList(), 'CC list', 'cc')
477 SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL',
478 'tree-status-url')
479 SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url')
480
481 # TODO: configure a default branch to diff against, rather than this
482 # svn-based hackery.
483
484
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000485class ChangeDescription(object):
486 """Contains a parsed form of the change description."""
487 def __init__(self, subject, log_desc, reviewers):
488 self.subject = subject
489 self.log_desc = log_desc
490 self.reviewers = reviewers
491 self.description = self.log_desc
492
493 def Update(self):
494 initial_text = """# Enter a description of the change.
495# This will displayed on the codereview site.
496# The first line will also be used as the subject of the review.
497"""
498 initial_text += self.description
499 if 'R=' not in self.description and self.reviewers:
500 initial_text += '\nR=' + self.reviewers
501 if 'BUG=' not in self.description:
502 initial_text += '\nBUG='
503 if 'TEST=' not in self.description:
504 initial_text += '\nTEST='
505 self._ParseDescription(UserEditedLog(initial_text))
506
507 def _ParseDescription(self, description):
508 if not description:
509 self.description = description
510 return
511
512 parsed_lines = []
513 reviewers_regexp = re.compile('\s*R=(.+)')
514 reviewers = ''
515 subject = ''
516 for l in description.splitlines():
517 if not subject:
518 subject = l
519 matched_reviewers = reviewers_regexp.match(l)
520 if matched_reviewers:
521 reviewers = matched_reviewers.group(1)
522 parsed_lines.append(l)
523
524 self.description = '\n'.join(parsed_lines) + '\n'
525 self.subject = subject
526 self.reviewers = reviewers
527
528 def IsEmpty(self):
529 return not self.description
530
531
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000532def FindCodereviewSettingsFile(filename='codereview.settings'):
533 """Finds the given file starting in the cwd and going up.
534
535 Only looks up to the top of the repository unless an
536 'inherit-review-settings-ok' file exists in the root of the repository.
537 """
538 inherit_ok_file = 'inherit-review-settings-ok'
539 cwd = os.getcwd()
540 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
541 if os.path.isfile(os.path.join(root, inherit_ok_file)):
542 root = '/'
543 while True:
544 if filename in os.listdir(cwd):
545 if os.path.isfile(os.path.join(cwd, filename)):
546 return open(os.path.join(cwd, filename))
547 if cwd == root:
548 break
549 cwd = os.path.dirname(cwd)
550
551
552def LoadCodereviewSettingsFromFile(fileobj):
553 """Parse a codereview.settings file and updates hooks."""
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000554 keyvals = {}
555 for line in fileobj.read().splitlines():
556 if not line or line.startswith("#"):
557 continue
558 k, v = line.split(": ", 1)
559 keyvals[k] = v
560
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000561 def SetProperty(name, setting, unset_error_ok=False):
562 fullname = 'rietveld.' + name
563 if setting in keyvals:
564 RunGit(['config', fullname, keyvals[setting]])
565 else:
566 RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
567
568 SetProperty('server', 'CODE_REVIEW_SERVER')
569 # Only server setting is required. Other settings can be absent.
570 # In that case, we ignore errors raised during option deletion attempt.
571 SetProperty('cc', 'CC_LIST', unset_error_ok=True)
572 SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
573 SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
574
575 if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
576 #should be of the form
577 #PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
578 #ORIGIN_URL_CONFIG: http://src.chromium.org/git
579 RunGit(['config', keyvals['PUSH_URL_CONFIG'],
580 keyvals['ORIGIN_URL_CONFIG']])
581
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000582
583@usage('[repo root containing codereview.settings]')
584def CMDconfig(parser, args):
585 """edit configuration for this tree"""
586
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000587 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000588 if len(args) == 0:
589 GetCodereviewSettingsInteractively()
590 return 0
591
592 url = args[0]
593 if not url.endswith('codereview.settings'):
594 url = os.path.join(url, 'codereview.settings')
595
596 # Load code review settings and download hooks (if available).
597 LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
598 return 0
599
600
601def CMDstatus(parser, args):
602 """show status of changelists"""
603 parser.add_option('--field',
604 help='print only specific field (desc|id|patch|url)')
605 (options, args) = parser.parse_args(args)
606
607 # TODO: maybe make show_branches a flag if necessary.
608 show_branches = not options.field
609
610 if show_branches:
611 branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
612 if branches:
613 print 'Branches associated with reviews:'
614 for branch in sorted(branches.splitlines()):
615 cl = Changelist(branchref=branch)
616 print " %10s: %s" % (cl.GetBranch(), cl.GetIssue())
617
618 cl = Changelist()
619 if options.field:
620 if options.field.startswith('desc'):
621 print cl.GetDescription()
622 elif options.field == 'id':
623 issueid = cl.GetIssue()
624 if issueid:
625 print issueid
626 elif options.field == 'patch':
627 patchset = cl.GetPatchset()
628 if patchset:
629 print patchset
630 elif options.field == 'url':
631 url = cl.GetIssueURL()
632 if url:
633 print url
634 else:
635 print
636 print 'Current branch:',
637 if not cl.GetIssue():
638 print 'no issue assigned.'
639 return 0
640 print cl.GetBranch()
641 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
642 print 'Issue description:'
643 print cl.GetDescription(pretty=True)
644 return 0
645
646
647@usage('[issue_number]')
648def CMDissue(parser, args):
649 """Set or display the current code review issue number.
650
651 Pass issue number 0 to clear the current issue.
652"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000653 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000654
655 cl = Changelist()
656 if len(args) > 0:
657 try:
658 issue = int(args[0])
659 except ValueError:
660 DieWithError('Pass a number to set the issue or none to list it.\n'
661 'Maybe you want to run git cl status?')
662 cl.SetIssue(issue)
663 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
664 return 0
665
666
667def CreateDescriptionFromLog(args):
668 """Pulls out the commit log to use as a base for the CL description."""
669 log_args = []
670 if len(args) == 1 and not args[0].endswith('.'):
671 log_args = [args[0] + '..']
672 elif len(args) == 1 and args[0].endswith('...'):
673 log_args = [args[0][:-1]]
674 elif len(args) == 2:
675 log_args = [args[0] + '..' + args[1]]
676 else:
677 log_args = args[:] # Hope for the best!
678 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
679
680
681def UserEditedLog(starting_text):
682 """Given some starting text, let the user edit it and return the result."""
683 editor = os.getenv('EDITOR', 'vi')
684
685 (file_handle, filename) = tempfile.mkstemp()
686 fileobj = os.fdopen(file_handle, 'w')
687 fileobj.write(starting_text)
688 fileobj.close()
689
mhm@chromium.orgd161d842011-03-12 18:56:50 +0000690 # Open up the default editor in the system to get the CL description.
mhm@chromium.org7a099612011-03-16 03:26:59 +0000691 try:
692 cmd = '%s %s' % (editor, filename)
693 if sys.platform == 'win32' and os.environ.get('TERM') == 'msys':
694 # Msysgit requires the usage of 'env' to be present.
695 cmd = 'env ' + cmd
696 # shell=True to allow the shell to handle all forms of quotes in $EDITOR.
697 subprocess.check_call(cmd, shell=True)
698 fileobj = open(filename)
699 text = fileobj.read()
700 fileobj.close()
701 finally:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000702 os.remove(filename)
thomasvl@chromium.org68f27462011-03-14 14:54:27 +0000703
mhm@chromium.org7a099612011-03-16 03:26:59 +0000704 if not text:
705 return
dpranke@chromium.org37248c02011-03-12 04:36:48 +0000706
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000707 stripcomment_re = re.compile(r'^#.*$', re.MULTILINE)
thomasvl@chromium.org68f27462011-03-14 14:54:27 +0000708 return stripcomment_re.sub('', text).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000709
710
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000711def ConvertToInteger(inputval):
712 """Convert a string to integer, but returns either an int or None."""
713 try:
714 return int(inputval)
715 except (TypeError, ValueError):
716 return None
717
718
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000719def RunHook(committing, upstream_branch, rietveld_server, tbr, may_prompt):
720 """Calls sys.exit() if the hook fails; returns a HookResults otherwise."""
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000721 import presubmit_support
722 import scm
723 import watchlists
724
725 root = RunCommand(['git', 'rev-parse', '--show-cdup']).strip()
726 if not root:
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000727 root = '.'
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000728 absroot = os.path.abspath(root)
729 if not root:
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000730 raise Exception('Could not get root directory.')
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000731
732 # We use the sha1 of HEAD as a name of this change.
733 name = RunCommand(['git', 'rev-parse', 'HEAD']).strip()
734 files = scm.GIT.CaptureStatus([root], upstream_branch)
735
736 cl = Changelist()
737 issue = ConvertToInteger(cl.GetIssue())
738 patchset = ConvertToInteger(cl.GetPatchset())
739 if issue:
740 description = cl.GetDescription()
741 else:
742 # If the change was never uploaded, use the log messages of all commits
743 # up to the branch point, as git cl upload will prefill the description
744 # with these log messages.
745 description = RunCommand(['git', 'log', '--pretty=format:%s%n%n%b',
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000746 '%s...' % (upstream_branch)]).strip()
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000747 change = presubmit_support.GitChange(name, description, absroot, files,
748 issue, patchset)
749
750 # Apply watchlists on upload.
751 if not committing:
752 watchlist = watchlists.Watchlists(change.RepositoryRoot())
753 files = [f.LocalPath() for f in change.AffectedFiles()]
754 watchers = watchlist.GetWatchersForPaths(files)
755 RunCommand(['git', 'config', '--replace-all',
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000756 'rietveld.extracc', ','.join(watchers)])
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000757
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000758 output = presubmit_support.DoPresubmitChecks(change, committing,
759 verbose=False, output_stream=sys.stdout, input_stream=sys.stdin,
760 default_presubmit=None, may_prompt=may_prompt, tbr=tbr,
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000761 host_url=cl.GetRietveldServer())
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000762
763 # TODO(dpranke): We should propagate the error out instead of calling exit().
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000764 if not output.should_continue():
765 sys.exit(1)
dpranke@chromium.org37248c02011-03-12 04:36:48 +0000766
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000767 return output
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000768
769
770def CMDpresubmit(parser, args):
771 """run presubmit tests on the current changelist"""
772 parser.add_option('--upload', action='store_true',
773 help='Run upload hook instead of the push/dcommit hook')
774 (options, args) = parser.parse_args(args)
775
776 # Make sure index is up-to-date before running diff-index.
777 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
778 if RunGit(['diff-index', 'HEAD']):
779 # TODO(maruel): Is this really necessary?
780 print 'Cannot presubmit with a dirty tree. You must commit locally first.'
781 return 1
782
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000783 cl = Changelist()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000784 if args:
785 base_branch = args[0]
786 else:
787 # Default to diffing against the "upstream" branch.
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000788 base_branch = cl.GetUpstreamBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000789
790 if options.upload:
791 print '*** Presubmit checks for UPLOAD would report: ***'
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000792 RunHook(committing=False, upstream_branch=base_branch,
793 rietveld_server=cl.GetRietveldServer(), tbr=False,
794 may_prompt=False)
795 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000796 else:
797 print '*** Presubmit checks for DCOMMIT would report: ***'
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000798 RunHook(committing=True, upstream_branch=base_branch,
799 rietveld_server=cl.GetRietveldServer, tbr=False,
800 may_prompt=False)
801 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000802
803
804@usage('[args to "git diff"]')
805def CMDupload(parser, args):
806 """upload the current changelist to codereview"""
807 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
808 help='bypass upload presubmit hook')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000809 parser.add_option('-f', action='store_true', dest='force',
810 help="force yes to questions (don't prompt)")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000811 parser.add_option('-m', dest='message', help='message for patch')
812 parser.add_option('-r', '--reviewers',
813 help='reviewer email addresses')
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000814 parser.add_option('--cc',
815 help='cc email addresses')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000816 parser.add_option('--send-mail', action='store_true',
817 help='send email to reviewer immediately')
818 parser.add_option("--emulate_svn_auto_props", action="store_true",
819 dest="emulate_svn_auto_props",
820 help="Emulate Subversion's auto properties feature.")
821 parser.add_option("--desc_from_logs", action="store_true",
822 dest="from_logs",
823 help="""Squashes git commit logs into change description and
824 uses message as subject""")
825 (options, args) = parser.parse_args(args)
826
827 # Make sure index is up-to-date before running diff-index.
828 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
829 if RunGit(['diff-index', 'HEAD']):
830 print 'Cannot upload with a dirty tree. You must commit locally first.'
831 return 1
832
833 cl = Changelist()
834 if args:
835 base_branch = args[0]
836 else:
837 # Default to diffing against the "upstream" branch.
838 base_branch = cl.GetUpstreamBranch()
839 args = [base_branch + "..."]
840
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000841 if not options.bypass_hooks and not options.force:
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000842 hook_results = RunHook(committing=False, upstream_branch=base_branch,
843 rietveld_server=cl.GetRietveldServer(), tbr=False,
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000844 may_prompt=True)
845 if not options.reviewers and hook_results.reviewers:
846 options.reviewers = hook_results.reviewers
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000847
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000848
849 # --no-ext-diff is broken in some versions of Git, so try to work around
850 # this by overriding the environment (but there is still a problem if the
851 # git config key "diff.external" is used).
852 env = os.environ.copy()
853 if 'GIT_EXTERNAL_DIFF' in env:
854 del env['GIT_EXTERNAL_DIFF']
855 subprocess.call(['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args,
856 env=env)
857
858 upload_args = ['--assume_yes'] # Don't ask about untracked files.
859 upload_args.extend(['--server', cl.GetRietveldServer()])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000860 if options.emulate_svn_auto_props:
861 upload_args.append('--emulate_svn_auto_props')
862 if options.send_mail:
863 if not options.reviewers:
864 DieWithError("Must specify reviewers to send email.")
865 upload_args.append('--send_mail')
866 if options.from_logs and not options.message:
867 print 'Must set message for subject line if using desc_from_logs'
868 return 1
869
870 change_desc = None
871
872 if cl.GetIssue():
873 if options.message:
874 upload_args.extend(['--message', options.message])
875 upload_args.extend(['--issue', cl.GetIssue()])
876 print ("This branch is associated with issue %s. "
877 "Adding patch to that issue." % cl.GetIssue())
878 else:
879 log_desc = CreateDescriptionFromLog(args)
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000880 change_desc = ChangeDescription(options.message, log_desc,
881 options.reviewers)
882 if not options.from_logs:
883 change_desc.Update()
884
885 if change_desc.IsEmpty():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000886 print "Description is empty; aborting."
887 return 1
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000888
889 upload_args.extend(['--message', change_desc.subject])
890 upload_args.extend(['--description', change_desc.description])
891 if change_desc.reviewers:
892 upload_args.extend(['--reviewers', change_desc.reviewers])
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000893 cc = ','.join(filter(None, (settings.GetCCList(), options.cc)))
894 if cc:
895 upload_args.extend(['--cc', cc])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000896
897 # Include the upstream repo's URL in the change -- this is useful for
898 # projects that have their source spread across multiple repos.
899 remote_url = None
900 if settings.GetIsGitSvn():
maruel@chromium.orgb92e4802011-03-03 20:22:00 +0000901 # URL is dependent on the current directory.
902 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000903 if data:
904 keys = dict(line.split(': ', 1) for line in data.splitlines()
905 if ': ' in line)
906 remote_url = keys.get('URL', None)
907 else:
908 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
909 remote_url = (cl.GetRemoteUrl() + '@'
910 + cl.GetUpstreamBranch().split('/')[-1])
911 if remote_url:
912 upload_args.extend(['--base_url', remote_url])
913
914 try:
915 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
916 except:
917 # If we got an exception after the user typed a description for their
918 # change, back up the description before re-raising.
919 if change_desc:
920 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
921 print '\nGot exception while uploading -- saving description to %s\n' \
922 % backup_path
923 backup_file = open(backup_path, 'w')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000924 backup_file.write(change_desc.description)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000925 backup_file.close()
926 raise
927
928 if not cl.GetIssue():
929 cl.SetIssue(issue)
930 cl.SetPatchset(patchset)
931 return 0
932
933
934def SendUpstream(parser, args, cmd):
935 """Common code for CmdPush and CmdDCommit
936
937 Squashed commit into a single.
938 Updates changelog with metadata (e.g. pointer to review).
939 Pushes/dcommits the code upstream.
940 Updates review and closes.
941 """
942 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
943 help='bypass upload presubmit hook')
944 parser.add_option('-m', dest='message',
945 help="override review description")
946 parser.add_option('-f', action='store_true', dest='force',
947 help="force yes to questions (don't prompt)")
948 parser.add_option('-c', dest='contributor',
949 help="external contributor for patch (appended to " +
950 "description and used as author for git). Should be " +
951 "formatted as 'First Last <email@example.com>'")
952 parser.add_option('--tbr', action='store_true', dest='tbr',
953 help="short for 'to be reviewed', commit branch " +
954 "even without uploading for review")
955 (options, args) = parser.parse_args(args)
956 cl = Changelist()
957
958 if not args or cmd == 'push':
959 # Default to merging against our best guess of the upstream branch.
960 args = [cl.GetUpstreamBranch()]
961
962 base_branch = args[0]
963
chase@chromium.orgc76e6752011-01-10 18:17:12 +0000964 # Make sure index is up-to-date before running diff-index.
965 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000966 if RunGit(['diff-index', 'HEAD']):
967 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
968 return 1
969
970 # This rev-list syntax means "show all commits not in my branch that
971 # are in base_branch".
972 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
973 base_branch]).splitlines()
974 if upstream_commits:
975 print ('Base branch "%s" has %d commits '
976 'not in this branch.' % (base_branch, len(upstream_commits)))
977 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
978 return 1
979
980 if cmd == 'dcommit':
981 # This is the revision `svn dcommit` will commit on top of.
982 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
983 '--pretty=format:%H'])
984 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
985 if extra_commits:
986 print ('This branch has %d additional commits not upstreamed yet.'
987 % len(extra_commits.splitlines()))
988 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
989 'before attempting to %s.' % (base_branch, cmd))
990 return 1
991
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000992 if not options.bypass_hooks and not options.force:
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000993 RunHook(committing=True, upstream_branch=base_branch,
994 rietveld_server=cl.GetRietveldServer(), tbr=options.tbr,
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000995 may_prompt=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000996
997 if cmd == 'dcommit':
998 # Check the tree status if the tree status URL is set.
999 status = GetTreeStatus()
1000 if 'closed' == status:
1001 print ('The tree is closed. Please wait for it to reopen. Use '
1002 '"git cl dcommit -f" to commit on a closed tree.')
1003 return 1
1004 elif 'unknown' == status:
1005 print ('Unable to determine tree status. Please verify manually and '
1006 'use "git cl dcommit -f" to commit on a closed tree.')
1007
1008 description = options.message
1009 if not options.tbr:
1010 # It is important to have these checks early. Not only for user
1011 # convenience, but also because the cl object then caches the correct values
1012 # of these fields even as we're juggling branches for setting up the commit.
1013 if not cl.GetIssue():
1014 print 'Current issue unknown -- has this branch been uploaded?'
1015 print 'Use --tbr to commit without review.'
1016 return 1
1017
1018 if not description:
1019 description = cl.GetDescription()
1020
1021 if not description:
1022 print 'No description set.'
1023 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
1024 return 1
1025
1026 description += "\n\nReview URL: %s" % cl.GetIssueURL()
1027 else:
1028 if not description:
1029 # Submitting TBR. See if there's already a description in Rietveld, else
1030 # create a template description. Eitherway, give the user a chance to edit
1031 # it to fill in the TBR= field.
1032 if cl.GetIssue():
1033 description = cl.GetDescription()
1034
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001035 # TODO(dpranke): Update to use ChangeDescription object.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001036 if not description:
1037 description = """# Enter a description of the change.
1038# This will be used as the change log for the commit.
1039
1040"""
1041 description += CreateDescriptionFromLog(args)
1042
1043 description = UserEditedLog(description + '\nTBR=')
1044
1045 if not description:
1046 print "Description empty; aborting."
1047 return 1
1048
1049 if options.contributor:
1050 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
1051 print "Please provide contibutor as 'First Last <email@example.com>'"
1052 return 1
1053 description += "\nPatch from %s." % options.contributor
1054 print 'Description:', repr(description)
1055
1056 branches = [base_branch, cl.GetBranchRef()]
1057 if not options.force:
1058 subprocess.call(['git', 'diff', '--stat'] + branches)
1059 raw_input("About to commit; enter to confirm.")
1060
1061 # We want to squash all this branch's commits into one commit with the
1062 # proper description.
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001063 # We do this by doing a "reset --soft" to the base branch (which keeps
1064 # the working copy the same), then dcommitting that.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001065 MERGE_BRANCH = 'git-cl-commit'
1066 # Delete the merge branch if it already exists.
1067 if RunGitWithCode(['show-ref', '--quiet', '--verify',
1068 'refs/heads/' + MERGE_BRANCH])[0] == 0:
1069 RunGit(['branch', '-D', MERGE_BRANCH])
1070
1071 # We might be in a directory that's present in this branch but not in the
1072 # trunk. Move up to the top of the tree so that git commands that expect a
1073 # valid CWD won't fail after we check out the merge branch.
1074 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
1075 if rel_base_path:
1076 os.chdir(rel_base_path)
1077
1078 # Stuff our change into the merge branch.
1079 # We wrap in a try...finally block so if anything goes wrong,
1080 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001081 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001082 try:
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001083 RunGit(['checkout', '-q', '-b', MERGE_BRANCH])
1084 RunGit(['reset', '--soft', base_branch])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001085 if options.contributor:
1086 RunGit(['commit', '--author', options.contributor, '-m', description])
1087 else:
1088 RunGit(['commit', '-m', description])
1089 if cmd == 'push':
1090 # push the merge branch.
1091 remote, branch = cl.FetchUpstreamTuple()
1092 retcode, output = RunGitWithCode(
1093 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1094 logging.debug(output)
1095 else:
1096 # dcommit the merge branch.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001097 retcode, output = RunGitWithCode(['svn', 'dcommit', '--no-rebase'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001098 finally:
1099 # And then swap back to the original branch and clean up.
1100 RunGit(['checkout', '-q', cl.GetBranch()])
1101 RunGit(['branch', '-D', MERGE_BRANCH])
1102
1103 if cl.GetIssue():
1104 if cmd == 'dcommit' and 'Committed r' in output:
1105 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1106 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001107 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1108 for l in output.splitlines(False))
1109 match = filter(None, match)
1110 if len(match) != 1:
1111 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1112 output)
1113 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001114 else:
1115 return 1
1116 viewvc_url = settings.GetViewVCUrl()
1117 if viewvc_url and revision:
1118 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1119 print ('Closing issue '
1120 '(you may be prompted for your codereview password)...')
1121 cl.CloseIssue()
1122 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001123
1124 if retcode == 0:
1125 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1126 if os.path.isfile(hook):
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001127 RunCommand([hook, base_branch], error_ok=True)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001128
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001129 return 0
1130
1131
1132@usage('[upstream branch to apply against]')
1133def CMDdcommit(parser, args):
1134 """commit the current changelist via git-svn"""
1135 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001136 message = """This doesn't appear to be an SVN repository.
1137If your project has a git mirror with an upstream SVN master, you probably need
1138to run 'git svn init', see your project's git mirror documentation.
1139If your project has a true writeable upstream repository, you probably want
1140to run 'git cl push' instead.
1141Choose wisely, if you get this wrong, your commit might appear to succeed but
1142will instead be silently ignored."""
1143 print(message)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001144 raw_input('[Press enter to dcommit or ctrl-C to quit]')
1145 return SendUpstream(parser, args, 'dcommit')
1146
1147
1148@usage('[upstream branch to apply against]')
1149def CMDpush(parser, args):
1150 """commit the current changelist via git"""
1151 if settings.GetIsGitSvn():
1152 print('This appears to be an SVN repository.')
1153 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
1154 raw_input('[Press enter to push or ctrl-C to quit]')
1155 return SendUpstream(parser, args, 'push')
1156
1157
1158@usage('<patch url or issue id>')
1159def CMDpatch(parser, args):
1160 """patch in a code review"""
1161 parser.add_option('-b', dest='newbranch',
1162 help='create a new branch off trunk for the patch')
1163 parser.add_option('-f', action='store_true', dest='force',
1164 help='with -b, clobber any existing branch')
1165 parser.add_option('--reject', action='store_true', dest='reject',
1166 help='allow failed patches and spew .rej files')
1167 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1168 help="don't commit after patch applies")
1169 (options, args) = parser.parse_args(args)
1170 if len(args) != 1:
1171 parser.print_help()
1172 return 1
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001173 issue_arg = args[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001174
1175 if re.match(r'\d+', input):
1176 # Input is an issue id. Figure out the URL.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001177 issue = issue_arg
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001178 server = settings.GetDefaultServerUrl()
1179 fetch = urllib2.urlopen('%s/%s' % (server, issue)).read()
1180 m = re.search(r'/download/issue[0-9]+_[0-9]+.diff', fetch)
1181 if not m:
1182 DieWithError('Must pass an issue ID or full URL for '
1183 '\'Download raw patch set\'')
1184 url = '%s%s' % (server, m.group(0).strip())
1185 else:
1186 # Assume it's a URL to the patch. Default to http.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001187 issue_url = FixUrl(issue_arg)
1188 match = re.match(r'.*?/issue(\d+)_\d+.diff', issue_url)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001189 if match:
1190 issue = match.group(1)
1191 url = input
1192 else:
1193 DieWithError('Must pass an issue ID or full URL for '
1194 '\'Download raw patch set\'')
1195
1196 if options.newbranch:
1197 if options.force:
1198 RunGit(['branch', '-D', options.newbranch],
1199 swallow_stderr=True, error_ok=True)
1200 RunGit(['checkout', '-b', options.newbranch,
1201 Changelist().GetUpstreamBranch()])
1202
1203 # Switch up to the top-level directory, if necessary, in preparation for
1204 # applying the patch.
1205 top = RunGit(['rev-parse', '--show-cdup']).strip()
1206 if top:
1207 os.chdir(top)
1208
1209 patch_data = urllib2.urlopen(url).read()
1210 # Git patches have a/ at the beginning of source paths. We strip that out
1211 # with a sed script rather than the -p flag to patch so we can feed either
1212 # Git or svn-style patches into the same apply command.
1213 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
1214 sed_proc = Popen(['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'],
1215 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
1216 patch_data = sed_proc.communicate(patch_data)[0]
1217 if sed_proc.returncode:
1218 DieWithError('Git patch mungling failed.')
1219 logging.info(patch_data)
1220 # We use "git apply" to apply the patch instead of "patch" so that we can
1221 # pick up file adds.
1222 # The --index flag means: also insert into the index (so we catch adds).
1223 cmd = ['git', 'apply', '--index', '-p0']
1224 if options.reject:
1225 cmd.append('--reject')
1226 patch_proc = Popen(cmd, stdin=subprocess.PIPE)
1227 patch_proc.communicate(patch_data)
1228 if patch_proc.returncode:
1229 DieWithError('Failed to apply the patch')
1230
1231 # If we had an issue, commit the current state and register the issue.
1232 if not options.nocommit:
1233 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1234 cl = Changelist()
1235 cl.SetIssue(issue)
1236 print "Committed patch."
1237 else:
1238 print "Patch applied to index."
1239 return 0
1240
1241
1242def CMDrebase(parser, args):
1243 """rebase current branch on top of svn repo"""
1244 # Provide a wrapper for git svn rebase to help avoid accidental
1245 # git svn dcommit.
1246 # It's the only command that doesn't use parser at all since we just defer
1247 # execution to git-svn.
1248 RunGit(['svn', 'rebase'] + args, redirect_stdout=False)
1249 return 0
1250
1251
1252def GetTreeStatus():
1253 """Fetches the tree status and returns either 'open', 'closed',
1254 'unknown' or 'unset'."""
1255 url = settings.GetTreeStatusUrl(error_ok=True)
1256 if url:
1257 status = urllib2.urlopen(url).read().lower()
1258 if status.find('closed') != -1 or status == '0':
1259 return 'closed'
1260 elif status.find('open') != -1 or status == '1':
1261 return 'open'
1262 return 'unknown'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001263 return 'unset'
1264
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001265
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001266def GetTreeStatusReason():
1267 """Fetches the tree status from a json url and returns the message
1268 with the reason for the tree to be opened or closed."""
1269 # Don't import it at file level since simplejson is not installed by default
1270 # on python 2.5 and it is only used for git-cl tree which isn't often used,
1271 # forcing everyone to install simplejson isn't efficient.
1272 try:
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001273 import simplejson as json # pylint: disable=F0401
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001274 except ImportError:
1275 try:
1276 import json
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001277 except ImportError:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001278 print >> sys.stderr, 'Please install simplejson'
1279 sys.exit(1)
1280
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001281 url = settings.GetTreeStatusUrl()
1282 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001283 connection = urllib2.urlopen(json_url)
1284 status = json.loads(connection.read())
1285 connection.close()
1286 return status['message']
1287
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001288
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001289def CMDtree(parser, args):
1290 """show the status of the tree"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001291 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001292 status = GetTreeStatus()
1293 if 'unset' == status:
1294 print 'You must configure your tree status URL by running "git cl config".'
1295 return 2
1296
1297 print "The tree is %s" % status
1298 print
1299 print GetTreeStatusReason()
1300 if status != 'open':
1301 return 1
1302 return 0
1303
1304
1305def CMDupstream(parser, args):
1306 """print the name of the upstream branch, if any"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001307 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001308 cl = Changelist()
1309 print cl.GetUpstreamBranch()
1310 return 0
1311
1312
1313def Command(name):
1314 return getattr(sys.modules[__name__], 'CMD' + name, None)
1315
1316
1317def CMDhelp(parser, args):
1318 """print list of commands or help for a specific command"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001319 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001320 if len(args) == 1:
1321 return main(args + ['--help'])
1322 parser.print_help()
1323 return 0
1324
1325
1326def GenUsage(parser, command):
1327 """Modify an OptParse object with the function's documentation."""
1328 obj = Command(command)
1329 more = getattr(obj, 'usage_more', '')
1330 if command == 'help':
1331 command = '<command>'
1332 else:
1333 # OptParser.description prefer nicely non-formatted strings.
1334 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1335 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1336
1337
1338def main(argv):
1339 """Doesn't parse the arguments here, just find the right subcommand to
1340 execute."""
1341 # Do it late so all commands are listed.
1342 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1343 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1344 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1345
1346 # Create the option parse and add --verbose support.
1347 parser = optparse.OptionParser()
1348 parser.add_option('-v', '--verbose', action='store_true')
1349 old_parser_args = parser.parse_args
1350 def Parse(args):
1351 options, args = old_parser_args(args)
1352 if options.verbose:
1353 logging.basicConfig(level=logging.DEBUG)
1354 else:
1355 logging.basicConfig(level=logging.WARNING)
1356 return options, args
1357 parser.parse_args = Parse
1358
1359 if argv:
1360 command = Command(argv[0])
1361 if command:
1362 # "fix" the usage and the description now that we know the subcommand.
1363 GenUsage(parser, argv[0])
1364 try:
1365 return command(parser, argv[1:])
1366 except urllib2.HTTPError, e:
1367 if e.code != 500:
1368 raise
1369 DieWithError(
1370 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1371 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1372
1373 # Not a known command. Default to help.
1374 GenUsage(parser, 'help')
1375 return CMDhelp(parser, argv)
1376
1377
1378if __name__ == '__main__':
1379 sys.exit(main(sys.argv[1:]))