blob: 1d12d2fb6d25b532c95311aa05bc97d8b0f8274d [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
dpranke@chromium.org970c5222011-03-12 00:32:24 +000010import StringIO
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000011import subprocess
12import sys
13import tempfile
14import textwrap
15import upload
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +000016import urlparse
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000017import urllib2
18
19try:
20 import readline
21except ImportError:
22 pass
23
24try:
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)
32 import breakpad
33except 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 HookResults(object):
486 """Contains the parsed output of the presubmit hooks."""
487 def __init__(self, output_from_hooks=None):
488 self.reviewers = []
489 self.output = None
490 self._ParseOutputFromHooks(output_from_hooks)
491
492 def _ParseOutputFromHooks(self, output_from_hooks):
493 if not output_from_hooks:
494 return
495 lines = []
496 reviewers = []
497 reviewer_regexp = re.compile('ADD: R=(.+)')
498 for l in output_from_hooks.splitlines():
499 m = reviewer_regexp.match(l)
500 if m:
501 reviewers.extend(m.group(1).split(','))
502 else:
503 lines.append(l)
504 self.output = '\n'.join(lines)
505 self.reviewers = ','.join(reviewers)
506
507
508class ChangeDescription(object):
509 """Contains a parsed form of the change description."""
510 def __init__(self, subject, log_desc, reviewers):
511 self.subject = subject
512 self.log_desc = log_desc
513 self.reviewers = reviewers
514 self.description = self.log_desc
515
516 def Update(self):
517 initial_text = """# Enter a description of the change.
518# This will displayed on the codereview site.
519# The first line will also be used as the subject of the review.
520"""
521 initial_text += self.description
522 if 'R=' not in self.description and self.reviewers:
523 initial_text += '\nR=' + self.reviewers
524 if 'BUG=' not in self.description:
525 initial_text += '\nBUG='
526 if 'TEST=' not in self.description:
527 initial_text += '\nTEST='
528 self._ParseDescription(UserEditedLog(initial_text))
529
530 def _ParseDescription(self, description):
531 if not description:
532 self.description = description
533 return
534
535 parsed_lines = []
536 reviewers_regexp = re.compile('\s*R=(.+)')
537 reviewers = ''
538 subject = ''
539 for l in description.splitlines():
540 if not subject:
541 subject = l
542 matched_reviewers = reviewers_regexp.match(l)
543 if matched_reviewers:
544 reviewers = matched_reviewers.group(1)
545 parsed_lines.append(l)
546
547 self.description = '\n'.join(parsed_lines) + '\n'
548 self.subject = subject
549 self.reviewers = reviewers
550
551 def IsEmpty(self):
552 return not self.description
553
554
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000555def FindCodereviewSettingsFile(filename='codereview.settings'):
556 """Finds the given file starting in the cwd and going up.
557
558 Only looks up to the top of the repository unless an
559 'inherit-review-settings-ok' file exists in the root of the repository.
560 """
561 inherit_ok_file = 'inherit-review-settings-ok'
562 cwd = os.getcwd()
563 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
564 if os.path.isfile(os.path.join(root, inherit_ok_file)):
565 root = '/'
566 while True:
567 if filename in os.listdir(cwd):
568 if os.path.isfile(os.path.join(cwd, filename)):
569 return open(os.path.join(cwd, filename))
570 if cwd == root:
571 break
572 cwd = os.path.dirname(cwd)
573
574
575def LoadCodereviewSettingsFromFile(fileobj):
576 """Parse a codereview.settings file and updates hooks."""
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000577 keyvals = {}
578 for line in fileobj.read().splitlines():
579 if not line or line.startswith("#"):
580 continue
581 k, v = line.split(": ", 1)
582 keyvals[k] = v
583
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000584 def SetProperty(name, setting, unset_error_ok=False):
585 fullname = 'rietveld.' + name
586 if setting in keyvals:
587 RunGit(['config', fullname, keyvals[setting]])
588 else:
589 RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
590
591 SetProperty('server', 'CODE_REVIEW_SERVER')
592 # Only server setting is required. Other settings can be absent.
593 # In that case, we ignore errors raised during option deletion attempt.
594 SetProperty('cc', 'CC_LIST', unset_error_ok=True)
595 SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
596 SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
597
598 if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
599 #should be of the form
600 #PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
601 #ORIGIN_URL_CONFIG: http://src.chromium.org/git
602 RunGit(['config', keyvals['PUSH_URL_CONFIG'],
603 keyvals['ORIGIN_URL_CONFIG']])
604
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000605
606@usage('[repo root containing codereview.settings]')
607def CMDconfig(parser, args):
608 """edit configuration for this tree"""
609
610 (options, args) = parser.parse_args(args)
611 if len(args) == 0:
612 GetCodereviewSettingsInteractively()
613 return 0
614
615 url = args[0]
616 if not url.endswith('codereview.settings'):
617 url = os.path.join(url, 'codereview.settings')
618
619 # Load code review settings and download hooks (if available).
620 LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
621 return 0
622
623
624def CMDstatus(parser, args):
625 """show status of changelists"""
626 parser.add_option('--field',
627 help='print only specific field (desc|id|patch|url)')
628 (options, args) = parser.parse_args(args)
629
630 # TODO: maybe make show_branches a flag if necessary.
631 show_branches = not options.field
632
633 if show_branches:
634 branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
635 if branches:
636 print 'Branches associated with reviews:'
637 for branch in sorted(branches.splitlines()):
638 cl = Changelist(branchref=branch)
639 print " %10s: %s" % (cl.GetBranch(), cl.GetIssue())
640
641 cl = Changelist()
642 if options.field:
643 if options.field.startswith('desc'):
644 print cl.GetDescription()
645 elif options.field == 'id':
646 issueid = cl.GetIssue()
647 if issueid:
648 print issueid
649 elif options.field == 'patch':
650 patchset = cl.GetPatchset()
651 if patchset:
652 print patchset
653 elif options.field == 'url':
654 url = cl.GetIssueURL()
655 if url:
656 print url
657 else:
658 print
659 print 'Current branch:',
660 if not cl.GetIssue():
661 print 'no issue assigned.'
662 return 0
663 print cl.GetBranch()
664 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
665 print 'Issue description:'
666 print cl.GetDescription(pretty=True)
667 return 0
668
669
670@usage('[issue_number]')
671def CMDissue(parser, args):
672 """Set or display the current code review issue number.
673
674 Pass issue number 0 to clear the current issue.
675"""
676 (options, args) = parser.parse_args(args)
677
678 cl = Changelist()
679 if len(args) > 0:
680 try:
681 issue = int(args[0])
682 except ValueError:
683 DieWithError('Pass a number to set the issue or none to list it.\n'
684 'Maybe you want to run git cl status?')
685 cl.SetIssue(issue)
686 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
687 return 0
688
689
690def CreateDescriptionFromLog(args):
691 """Pulls out the commit log to use as a base for the CL description."""
692 log_args = []
693 if len(args) == 1 and not args[0].endswith('.'):
694 log_args = [args[0] + '..']
695 elif len(args) == 1 and args[0].endswith('...'):
696 log_args = [args[0][:-1]]
697 elif len(args) == 2:
698 log_args = [args[0] + '..' + args[1]]
699 else:
700 log_args = args[:] # Hope for the best!
701 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
702
703
704def UserEditedLog(starting_text):
705 """Given some starting text, let the user edit it and return the result."""
706 editor = os.getenv('EDITOR', 'vi')
707
708 (file_handle, filename) = tempfile.mkstemp()
709 fileobj = os.fdopen(file_handle, 'w')
710 fileobj.write(starting_text)
711 fileobj.close()
712
mhm@chromium.orgd161d842011-03-12 18:56:50 +0000713 # Open up the default editor in the system to get the CL description.
714 cmd = [editor, filename]
mhm@chromium.org3e48aa72011-03-13 22:32:17 +0000715 if sys.platform == 'win32' and os.environ.get('TERM') == 'msys':
716 # Msysgit requires the usage of 'env' to be present.
mhm@chromium.orgd161d842011-03-12 18:56:50 +0000717 cmd.insert(0, 'env')
mhm@chromium.org3d4d7ed2011-03-12 02:19:19 +0000718 try:
mhm@chromium.orgd161d842011-03-12 18:56:50 +0000719 subprocess.check_call(cmd)
mhm@chromium.org3d4d7ed2011-03-12 02:19:19 +0000720 fileobj = open(filename)
721 result = fileobj.read()
722 fileobj.close()
723 finally:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000724 os.remove(filename)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000725
mhm@chromium.org3d4d7ed2011-03-12 02:19:19 +0000726 if not result:
727 return
dpranke@chromium.org37248c02011-03-12 04:36:48 +0000728
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000729 stripcomment_re = re.compile(r'^#.*$', re.MULTILINE)
mhm@chromium.org3d4d7ed2011-03-12 02:19:19 +0000730 return stripcomment_re.sub('', result).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000731
732
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000733def ConvertToInteger(inputval):
734 """Convert a string to integer, but returns either an int or None."""
735 try:
736 return int(inputval)
737 except (TypeError, ValueError):
738 return None
739
740
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000741def RunHook(committing, upstream_branch, rietveld_server, tbr, may_prompt):
742 """Calls sys.exit() if the hook fails; returns a HookResults otherwise."""
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000743 import presubmit_support
744 import scm
745 import watchlists
746
747 root = RunCommand(['git', 'rev-parse', '--show-cdup']).strip()
748 if not root:
749 root = "."
750 absroot = os.path.abspath(root)
751 if not root:
752 raise Exception("Could not get root directory.")
753
754 # We use the sha1 of HEAD as a name of this change.
755 name = RunCommand(['git', 'rev-parse', 'HEAD']).strip()
756 files = scm.GIT.CaptureStatus([root], upstream_branch)
757
758 cl = Changelist()
759 issue = ConvertToInteger(cl.GetIssue())
760 patchset = ConvertToInteger(cl.GetPatchset())
761 if issue:
762 description = cl.GetDescription()
763 else:
764 # If the change was never uploaded, use the log messages of all commits
765 # up to the branch point, as git cl upload will prefill the description
766 # with these log messages.
767 description = RunCommand(['git', 'log', '--pretty=format:%s%n%n%b',
768 '%s...' % (upstream_branch)]).strip()
769 change = presubmit_support.GitChange(name, description, absroot, files,
770 issue, patchset)
771
772 # Apply watchlists on upload.
773 if not committing:
774 watchlist = watchlists.Watchlists(change.RepositoryRoot())
775 files = [f.LocalPath() for f in change.AffectedFiles()]
776 watchers = watchlist.GetWatchersForPaths(files)
777 RunCommand(['git', 'config', '--replace-all',
778 'rietveld.extracc', ','.join(watchers)])
779
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000780 output = StringIO.StringIO()
dpranke@chromium.org5a2fefb2011-03-13 23:54:56 +0000781 should_continue = presubmit_support.DoPresubmitChecks(change, committing,
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000782 verbose=None, output_stream=output, input_stream=sys.stdin,
dpranke@chromium.org37248c02011-03-12 04:36:48 +0000783 default_presubmit=None, may_prompt=False, tbr=tbr,
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000784 host_url=cl.GetRietveldServer())
785 hook_results = HookResults(output.getvalue())
786 if hook_results.output:
787 print hook_results.output
788
789 # TODO(dpranke): We should propagate the error out instead of calling exit().
dpranke@chromium.org5a2fefb2011-03-13 23:54:56 +0000790 if should_continue and hook_results.output and (
791 '** Presubmit ERRORS **\n' in hook_results.output or
792 '** Presubmit WARNINGS **\n' in hook_results.output):
793 should_continue = False
dpranke@chromium.org37248c02011-03-12 04:36:48 +0000794
dpranke@chromium.org5a2fefb2011-03-13 23:54:56 +0000795 if not should_continue:
dpranke@chromium.org37248c02011-03-12 04:36:48 +0000796 if may_prompt:
797 response = raw_input('Are you sure you want to continue? (y/N): ')
798 if not response.lower().startswith('y'):
799 sys.exit(1)
800 else:
801 sys.exit(1)
802
803
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000804 return hook_results
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000805
806
807def CMDpresubmit(parser, args):
808 """run presubmit tests on the current changelist"""
809 parser.add_option('--upload', action='store_true',
810 help='Run upload hook instead of the push/dcommit hook')
811 (options, args) = parser.parse_args(args)
812
813 # Make sure index is up-to-date before running diff-index.
814 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
815 if RunGit(['diff-index', 'HEAD']):
816 # TODO(maruel): Is this really necessary?
817 print 'Cannot presubmit with a dirty tree. You must commit locally first.'
818 return 1
819
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000820 cl = Changelist()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000821 if args:
822 base_branch = args[0]
823 else:
824 # Default to diffing against the "upstream" branch.
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000825 base_branch = cl.GetUpstreamBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000826
827 if options.upload:
828 print '*** Presubmit checks for UPLOAD would report: ***'
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000829 RunHook(committing=False, upstream_branch=base_branch,
830 rietveld_server=cl.GetRietveldServer(), tbr=False,
831 may_prompt=False)
832 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000833 else:
834 print '*** Presubmit checks for DCOMMIT would report: ***'
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000835 RunHook(committing=True, upstream_branch=base_branch,
836 rietveld_server=cl.GetRietveldServer, tbr=False,
837 may_prompt=False)
838 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000839
840
841@usage('[args to "git diff"]')
842def CMDupload(parser, args):
843 """upload the current changelist to codereview"""
844 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
845 help='bypass upload presubmit hook')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000846 parser.add_option('-f', action='store_true', dest='force',
847 help="force yes to questions (don't prompt)")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000848 parser.add_option('-m', dest='message', help='message for patch')
849 parser.add_option('-r', '--reviewers',
850 help='reviewer email addresses')
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000851 parser.add_option('--cc',
852 help='cc email addresses')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000853 parser.add_option('--send-mail', action='store_true',
854 help='send email to reviewer immediately')
855 parser.add_option("--emulate_svn_auto_props", action="store_true",
856 dest="emulate_svn_auto_props",
857 help="Emulate Subversion's auto properties feature.")
858 parser.add_option("--desc_from_logs", action="store_true",
859 dest="from_logs",
860 help="""Squashes git commit logs into change description and
861 uses message as subject""")
862 (options, args) = parser.parse_args(args)
863
864 # Make sure index is up-to-date before running diff-index.
865 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
866 if RunGit(['diff-index', 'HEAD']):
867 print 'Cannot upload with a dirty tree. You must commit locally first.'
868 return 1
869
870 cl = Changelist()
871 if args:
872 base_branch = args[0]
873 else:
874 # Default to diffing against the "upstream" branch.
875 base_branch = cl.GetUpstreamBranch()
876 args = [base_branch + "..."]
877
878 if not options.bypass_hooks:
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000879 hook_results = RunHook(committing=False, upstream_branch=base_branch,
880 rietveld_server=cl.GetRietveldServer(), tbr=False,
881 may_prompt=(not options.force))
882 else:
883 hook_results = HookResults()
884
885 if not options.reviewers and hook_results.reviewers:
886 options.reviewers = hook_results.reviewers
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000887
888 # --no-ext-diff is broken in some versions of Git, so try to work around
889 # this by overriding the environment (but there is still a problem if the
890 # git config key "diff.external" is used).
891 env = os.environ.copy()
892 if 'GIT_EXTERNAL_DIFF' in env:
893 del env['GIT_EXTERNAL_DIFF']
894 subprocess.call(['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args,
895 env=env)
896
897 upload_args = ['--assume_yes'] # Don't ask about untracked files.
898 upload_args.extend(['--server', cl.GetRietveldServer()])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000899 if options.emulate_svn_auto_props:
900 upload_args.append('--emulate_svn_auto_props')
901 if options.send_mail:
902 if not options.reviewers:
903 DieWithError("Must specify reviewers to send email.")
904 upload_args.append('--send_mail')
905 if options.from_logs and not options.message:
906 print 'Must set message for subject line if using desc_from_logs'
907 return 1
908
909 change_desc = None
910
911 if cl.GetIssue():
912 if options.message:
913 upload_args.extend(['--message', options.message])
914 upload_args.extend(['--issue', cl.GetIssue()])
915 print ("This branch is associated with issue %s. "
916 "Adding patch to that issue." % cl.GetIssue())
917 else:
918 log_desc = CreateDescriptionFromLog(args)
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000919 change_desc = ChangeDescription(options.message, log_desc,
920 options.reviewers)
921 if not options.from_logs:
922 change_desc.Update()
923
924 if change_desc.IsEmpty():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000925 print "Description is empty; aborting."
926 return 1
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000927
928 upload_args.extend(['--message', change_desc.subject])
929 upload_args.extend(['--description', change_desc.description])
930 if change_desc.reviewers:
931 upload_args.extend(['--reviewers', change_desc.reviewers])
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000932 cc = ','.join(filter(None, (settings.GetCCList(), options.cc)))
933 if cc:
934 upload_args.extend(['--cc', cc])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000935
936 # Include the upstream repo's URL in the change -- this is useful for
937 # projects that have their source spread across multiple repos.
938 remote_url = None
939 if settings.GetIsGitSvn():
maruel@chromium.orgb92e4802011-03-03 20:22:00 +0000940 # URL is dependent on the current directory.
941 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000942 if data:
943 keys = dict(line.split(': ', 1) for line in data.splitlines()
944 if ': ' in line)
945 remote_url = keys.get('URL', None)
946 else:
947 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
948 remote_url = (cl.GetRemoteUrl() + '@'
949 + cl.GetUpstreamBranch().split('/')[-1])
950 if remote_url:
951 upload_args.extend(['--base_url', remote_url])
952
953 try:
954 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
955 except:
956 # If we got an exception after the user typed a description for their
957 # change, back up the description before re-raising.
958 if change_desc:
959 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
960 print '\nGot exception while uploading -- saving description to %s\n' \
961 % backup_path
962 backup_file = open(backup_path, 'w')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000963 backup_file.write(change_desc.description)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000964 backup_file.close()
965 raise
966
967 if not cl.GetIssue():
968 cl.SetIssue(issue)
969 cl.SetPatchset(patchset)
970 return 0
971
972
973def SendUpstream(parser, args, cmd):
974 """Common code for CmdPush and CmdDCommit
975
976 Squashed commit into a single.
977 Updates changelog with metadata (e.g. pointer to review).
978 Pushes/dcommits the code upstream.
979 Updates review and closes.
980 """
981 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
982 help='bypass upload presubmit hook')
983 parser.add_option('-m', dest='message',
984 help="override review description")
985 parser.add_option('-f', action='store_true', dest='force',
986 help="force yes to questions (don't prompt)")
987 parser.add_option('-c', dest='contributor',
988 help="external contributor for patch (appended to " +
989 "description and used as author for git). Should be " +
990 "formatted as 'First Last <email@example.com>'")
991 parser.add_option('--tbr', action='store_true', dest='tbr',
992 help="short for 'to be reviewed', commit branch " +
993 "even without uploading for review")
994 (options, args) = parser.parse_args(args)
995 cl = Changelist()
996
997 if not args or cmd == 'push':
998 # Default to merging against our best guess of the upstream branch.
999 args = [cl.GetUpstreamBranch()]
1000
1001 base_branch = args[0]
1002
chase@chromium.orgc76e6752011-01-10 18:17:12 +00001003 # Make sure index is up-to-date before running diff-index.
1004 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001005 if RunGit(['diff-index', 'HEAD']):
1006 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
1007 return 1
1008
1009 # This rev-list syntax means "show all commits not in my branch that
1010 # are in base_branch".
1011 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
1012 base_branch]).splitlines()
1013 if upstream_commits:
1014 print ('Base branch "%s" has %d commits '
1015 'not in this branch.' % (base_branch, len(upstream_commits)))
1016 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
1017 return 1
1018
1019 if cmd == 'dcommit':
1020 # This is the revision `svn dcommit` will commit on top of.
1021 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
1022 '--pretty=format:%H'])
1023 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
1024 if extra_commits:
1025 print ('This branch has %d additional commits not upstreamed yet.'
1026 % len(extra_commits.splitlines()))
1027 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
1028 'before attempting to %s.' % (base_branch, cmd))
1029 return 1
1030
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001031 if not options.bypass_hooks:
1032 RunHook(committing=True, upstream_branch=base_branch,
1033 rietveld_server=cl.GetRietveldServer(), tbr=options.tbr,
1034 may_prompt=(not options.force))
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001035
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001036 if not options.force and not options.bypass_hooks:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001037 if cmd == 'dcommit':
1038 # Check the tree status if the tree status URL is set.
1039 status = GetTreeStatus()
1040 if 'closed' == status:
1041 print ('The tree is closed. Please wait for it to reopen. Use '
1042 '"git cl dcommit -f" to commit on a closed tree.')
1043 return 1
1044 elif 'unknown' == status:
1045 print ('Unable to determine tree status. Please verify manually and '
1046 'use "git cl dcommit -f" to commit on a closed tree.')
1047
1048 description = options.message
1049 if not options.tbr:
1050 # It is important to have these checks early. Not only for user
1051 # convenience, but also because the cl object then caches the correct values
1052 # of these fields even as we're juggling branches for setting up the commit.
1053 if not cl.GetIssue():
1054 print 'Current issue unknown -- has this branch been uploaded?'
1055 print 'Use --tbr to commit without review.'
1056 return 1
1057
1058 if not description:
1059 description = cl.GetDescription()
1060
1061 if not description:
1062 print 'No description set.'
1063 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
1064 return 1
1065
1066 description += "\n\nReview URL: %s" % cl.GetIssueURL()
1067 else:
1068 if not description:
1069 # Submitting TBR. See if there's already a description in Rietveld, else
1070 # create a template description. Eitherway, give the user a chance to edit
1071 # it to fill in the TBR= field.
1072 if cl.GetIssue():
1073 description = cl.GetDescription()
1074
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001075 # TODO(dpranke): Update to use ChangeDescription object.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001076 if not description:
1077 description = """# Enter a description of the change.
1078# This will be used as the change log for the commit.
1079
1080"""
1081 description += CreateDescriptionFromLog(args)
1082
1083 description = UserEditedLog(description + '\nTBR=')
1084
1085 if not description:
1086 print "Description empty; aborting."
1087 return 1
1088
1089 if options.contributor:
1090 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
1091 print "Please provide contibutor as 'First Last <email@example.com>'"
1092 return 1
1093 description += "\nPatch from %s." % options.contributor
1094 print 'Description:', repr(description)
1095
1096 branches = [base_branch, cl.GetBranchRef()]
1097 if not options.force:
1098 subprocess.call(['git', 'diff', '--stat'] + branches)
1099 raw_input("About to commit; enter to confirm.")
1100
1101 # We want to squash all this branch's commits into one commit with the
1102 # proper description.
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001103 # We do this by doing a "reset --soft" to the base branch (which keeps
1104 # the working copy the same), then dcommitting that.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001105 MERGE_BRANCH = 'git-cl-commit'
1106 # Delete the merge branch if it already exists.
1107 if RunGitWithCode(['show-ref', '--quiet', '--verify',
1108 'refs/heads/' + MERGE_BRANCH])[0] == 0:
1109 RunGit(['branch', '-D', MERGE_BRANCH])
1110
1111 # We might be in a directory that's present in this branch but not in the
1112 # trunk. Move up to the top of the tree so that git commands that expect a
1113 # valid CWD won't fail after we check out the merge branch.
1114 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
1115 if rel_base_path:
1116 os.chdir(rel_base_path)
1117
1118 # Stuff our change into the merge branch.
1119 # We wrap in a try...finally block so if anything goes wrong,
1120 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001121 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001122 try:
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001123 RunGit(['checkout', '-q', '-b', MERGE_BRANCH])
1124 RunGit(['reset', '--soft', base_branch])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001125 if options.contributor:
1126 RunGit(['commit', '--author', options.contributor, '-m', description])
1127 else:
1128 RunGit(['commit', '-m', description])
1129 if cmd == 'push':
1130 # push the merge branch.
1131 remote, branch = cl.FetchUpstreamTuple()
1132 retcode, output = RunGitWithCode(
1133 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1134 logging.debug(output)
1135 else:
1136 # dcommit the merge branch.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001137 retcode, output = RunGitWithCode(['svn', 'dcommit', '--no-rebase'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001138 finally:
1139 # And then swap back to the original branch and clean up.
1140 RunGit(['checkout', '-q', cl.GetBranch()])
1141 RunGit(['branch', '-D', MERGE_BRANCH])
1142
1143 if cl.GetIssue():
1144 if cmd == 'dcommit' and 'Committed r' in output:
1145 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1146 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001147 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1148 for l in output.splitlines(False))
1149 match = filter(None, match)
1150 if len(match) != 1:
1151 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1152 output)
1153 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001154 else:
1155 return 1
1156 viewvc_url = settings.GetViewVCUrl()
1157 if viewvc_url and revision:
1158 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1159 print ('Closing issue '
1160 '(you may be prompted for your codereview password)...')
1161 cl.CloseIssue()
1162 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001163
1164 if retcode == 0:
1165 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1166 if os.path.isfile(hook):
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001167 RunCommand([hook, base_branch], error_ok=True)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001168
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001169 return 0
1170
1171
1172@usage('[upstream branch to apply against]')
1173def CMDdcommit(parser, args):
1174 """commit the current changelist via git-svn"""
1175 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001176 message = """This doesn't appear to be an SVN repository.
1177If your project has a git mirror with an upstream SVN master, you probably need
1178to run 'git svn init', see your project's git mirror documentation.
1179If your project has a true writeable upstream repository, you probably want
1180to run 'git cl push' instead.
1181Choose wisely, if you get this wrong, your commit might appear to succeed but
1182will instead be silently ignored."""
1183 print(message)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001184 raw_input('[Press enter to dcommit or ctrl-C to quit]')
1185 return SendUpstream(parser, args, 'dcommit')
1186
1187
1188@usage('[upstream branch to apply against]')
1189def CMDpush(parser, args):
1190 """commit the current changelist via git"""
1191 if settings.GetIsGitSvn():
1192 print('This appears to be an SVN repository.')
1193 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
1194 raw_input('[Press enter to push or ctrl-C to quit]')
1195 return SendUpstream(parser, args, 'push')
1196
1197
1198@usage('<patch url or issue id>')
1199def CMDpatch(parser, args):
1200 """patch in a code review"""
1201 parser.add_option('-b', dest='newbranch',
1202 help='create a new branch off trunk for the patch')
1203 parser.add_option('-f', action='store_true', dest='force',
1204 help='with -b, clobber any existing branch')
1205 parser.add_option('--reject', action='store_true', dest='reject',
1206 help='allow failed patches and spew .rej files')
1207 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1208 help="don't commit after patch applies")
1209 (options, args) = parser.parse_args(args)
1210 if len(args) != 1:
1211 parser.print_help()
1212 return 1
1213 input = args[0]
1214
1215 if re.match(r'\d+', input):
1216 # Input is an issue id. Figure out the URL.
1217 issue = input
1218 server = settings.GetDefaultServerUrl()
1219 fetch = urllib2.urlopen('%s/%s' % (server, issue)).read()
1220 m = re.search(r'/download/issue[0-9]+_[0-9]+.diff', fetch)
1221 if not m:
1222 DieWithError('Must pass an issue ID or full URL for '
1223 '\'Download raw patch set\'')
1224 url = '%s%s' % (server, m.group(0).strip())
1225 else:
1226 # Assume it's a URL to the patch. Default to http.
1227 input = FixUrl(input)
1228 match = re.match(r'.*?/issue(\d+)_\d+.diff', input)
1229 if match:
1230 issue = match.group(1)
1231 url = input
1232 else:
1233 DieWithError('Must pass an issue ID or full URL for '
1234 '\'Download raw patch set\'')
1235
1236 if options.newbranch:
1237 if options.force:
1238 RunGit(['branch', '-D', options.newbranch],
1239 swallow_stderr=True, error_ok=True)
1240 RunGit(['checkout', '-b', options.newbranch,
1241 Changelist().GetUpstreamBranch()])
1242
1243 # Switch up to the top-level directory, if necessary, in preparation for
1244 # applying the patch.
1245 top = RunGit(['rev-parse', '--show-cdup']).strip()
1246 if top:
1247 os.chdir(top)
1248
1249 patch_data = urllib2.urlopen(url).read()
1250 # Git patches have a/ at the beginning of source paths. We strip that out
1251 # with a sed script rather than the -p flag to patch so we can feed either
1252 # Git or svn-style patches into the same apply command.
1253 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
1254 sed_proc = Popen(['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'],
1255 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
1256 patch_data = sed_proc.communicate(patch_data)[0]
1257 if sed_proc.returncode:
1258 DieWithError('Git patch mungling failed.')
1259 logging.info(patch_data)
1260 # We use "git apply" to apply the patch instead of "patch" so that we can
1261 # pick up file adds.
1262 # The --index flag means: also insert into the index (so we catch adds).
1263 cmd = ['git', 'apply', '--index', '-p0']
1264 if options.reject:
1265 cmd.append('--reject')
1266 patch_proc = Popen(cmd, stdin=subprocess.PIPE)
1267 patch_proc.communicate(patch_data)
1268 if patch_proc.returncode:
1269 DieWithError('Failed to apply the patch')
1270
1271 # If we had an issue, commit the current state and register the issue.
1272 if not options.nocommit:
1273 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1274 cl = Changelist()
1275 cl.SetIssue(issue)
1276 print "Committed patch."
1277 else:
1278 print "Patch applied to index."
1279 return 0
1280
1281
1282def CMDrebase(parser, args):
1283 """rebase current branch on top of svn repo"""
1284 # Provide a wrapper for git svn rebase to help avoid accidental
1285 # git svn dcommit.
1286 # It's the only command that doesn't use parser at all since we just defer
1287 # execution to git-svn.
1288 RunGit(['svn', 'rebase'] + args, redirect_stdout=False)
1289 return 0
1290
1291
1292def GetTreeStatus():
1293 """Fetches the tree status and returns either 'open', 'closed',
1294 'unknown' or 'unset'."""
1295 url = settings.GetTreeStatusUrl(error_ok=True)
1296 if url:
1297 status = urllib2.urlopen(url).read().lower()
1298 if status.find('closed') != -1 or status == '0':
1299 return 'closed'
1300 elif status.find('open') != -1 or status == '1':
1301 return 'open'
1302 return 'unknown'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001303 return 'unset'
1304
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001305
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001306def GetTreeStatusReason():
1307 """Fetches the tree status from a json url and returns the message
1308 with the reason for the tree to be opened or closed."""
1309 # Don't import it at file level since simplejson is not installed by default
1310 # on python 2.5 and it is only used for git-cl tree which isn't often used,
1311 # forcing everyone to install simplejson isn't efficient.
1312 try:
1313 import simplejson as json
1314 except ImportError:
1315 try:
1316 import json
1317 # Some versions of python2.5 have an incomplete json module. Check to make
1318 # sure loads exists.
1319 json.loads
1320 except (ImportError, AttributeError):
1321 print >> sys.stderr, 'Please install simplejson'
1322 sys.exit(1)
1323
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001324 url = settings.GetTreeStatusUrl()
1325 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001326 connection = urllib2.urlopen(json_url)
1327 status = json.loads(connection.read())
1328 connection.close()
1329 return status['message']
1330
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001331
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001332def CMDtree(parser, args):
1333 """show the status of the tree"""
1334 (options, args) = parser.parse_args(args)
1335 status = GetTreeStatus()
1336 if 'unset' == status:
1337 print 'You must configure your tree status URL by running "git cl config".'
1338 return 2
1339
1340 print "The tree is %s" % status
1341 print
1342 print GetTreeStatusReason()
1343 if status != 'open':
1344 return 1
1345 return 0
1346
1347
1348def CMDupstream(parser, args):
1349 """print the name of the upstream branch, if any"""
1350 (options, args) = parser.parse_args(args)
1351 cl = Changelist()
1352 print cl.GetUpstreamBranch()
1353 return 0
1354
1355
1356def Command(name):
1357 return getattr(sys.modules[__name__], 'CMD' + name, None)
1358
1359
1360def CMDhelp(parser, args):
1361 """print list of commands or help for a specific command"""
1362 (options, args) = parser.parse_args(args)
1363 if len(args) == 1:
1364 return main(args + ['--help'])
1365 parser.print_help()
1366 return 0
1367
1368
1369def GenUsage(parser, command):
1370 """Modify an OptParse object with the function's documentation."""
1371 obj = Command(command)
1372 more = getattr(obj, 'usage_more', '')
1373 if command == 'help':
1374 command = '<command>'
1375 else:
1376 # OptParser.description prefer nicely non-formatted strings.
1377 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1378 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1379
1380
1381def main(argv):
1382 """Doesn't parse the arguments here, just find the right subcommand to
1383 execute."""
1384 # Do it late so all commands are listed.
1385 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1386 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1387 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1388
1389 # Create the option parse and add --verbose support.
1390 parser = optparse.OptionParser()
1391 parser.add_option('-v', '--verbose', action='store_true')
1392 old_parser_args = parser.parse_args
1393 def Parse(args):
1394 options, args = old_parser_args(args)
1395 if options.verbose:
1396 logging.basicConfig(level=logging.DEBUG)
1397 else:
1398 logging.basicConfig(level=logging.WARNING)
1399 return options, args
1400 parser.parse_args = Parse
1401
1402 if argv:
1403 command = Command(argv[0])
1404 if command:
1405 # "fix" the usage and the description now that we know the subcommand.
1406 GenUsage(parser, argv[0])
1407 try:
1408 return command(parser, argv[1:])
1409 except urllib2.HTTPError, e:
1410 if e.code != 500:
1411 raise
1412 DieWithError(
1413 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1414 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1415
1416 # Not a known command. Default to help.
1417 GenUsage(parser, 'help')
1418 return CMDhelp(parser, argv)
1419
1420
1421if __name__ == '__main__':
1422 sys.exit(main(sys.argv[1:]))