Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(273)

Side by Side Diff: git_cl.py

Issue 188383002: Refactor the way that git executables are launched in depot tools. (Closed) Base URL: https://chromium.googlesource.com/chromium/tools/depot_tools.git@master
Patch Set: Created 6 years, 9 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
OLDNEW
(Empty)
1 #!/usr/bin/env python
2 # Copyright (c) 2012 The Chromium Authors. All rights reserved.
3 # Use of this source code is governed by a BSD-style license that can be
4 # found in the LICENSE file.
5
6 # Copyright (C) 2008 Evan Martin <martine@danga.com>
7
8 """A git-command for integrating reviews on Rietveld."""
9
10 from distutils.version import LooseVersion
11 import glob
12 import json
13 import logging
14 import optparse
15 import os
16 import Queue
17 import re
18 import stat
19 import sys
20 import textwrap
21 import threading
22 import urllib2
23 import urlparse
24 import webbrowser
25
26 try:
27 import readline # pylint: disable=F0401,W0611
28 except ImportError:
29 pass
30
31
32 from third_party import colorama
33 from third_party import upload
34 import breakpad # pylint: disable=W0611
35 import clang_format
36 import fix_encoding
37 import gclient_utils
38 import presubmit_support
39 import rietveld
40 import scm
41 import subcommand
42 import subprocess2
43 import watchlists
44 import owners_finder
45
46 __version__ = '1.0'
47
48 DEFAULT_SERVER = 'https://codereview.appspot.com'
49 POSTUPSTREAM_HOOK_PATTERN = '.git/hooks/post-cl-%s'
50 DESCRIPTION_BACKUP_FILE = '~/.git_cl_description_backup'
51 GIT_INSTRUCTIONS_URL = 'http://code.google.com/p/chromium/wiki/UsingGit'
52 CHANGE_ID = 'Change-Id:'
53
54 # Shortcut since it quickly becomes redundant.
55 Fore = colorama.Fore
56
57 # Initialized in main()
58 settings = None
59
60
61 def DieWithError(message):
62 print >> sys.stderr, message
63 sys.exit(1)
64
65
66 def GetNoGitPagerEnv():
67 env = os.environ.copy()
68 # 'cat' is a magical git string that disables pagers on all platforms.
69 env['GIT_PAGER'] = 'cat'
70 return env
71
72 def RunCommand(args, error_ok=False, error_message=None, **kwargs):
73 try:
74 return subprocess2.check_output(args, shell=False, **kwargs)
75 except subprocess2.CalledProcessError as e:
76 logging.debug('Failed running %s', args)
77 if not error_ok:
78 DieWithError(
79 'Command "%s" failed.\n%s' % (
80 ' '.join(args), error_message or e.stdout or ''))
81 return e.stdout
82
83
84 def RunGit(args, **kwargs):
85 """Returns stdout."""
86 return RunCommand(['git'] + args, **kwargs)
87
88
89 def RunGitWithCode(args, suppress_stderr=False):
90 """Returns return code and stdout."""
91 try:
92 if suppress_stderr:
93 stderr = subprocess2.VOID
94 else:
95 stderr = sys.stderr
96 out, code = subprocess2.communicate(['git'] + args,
97 env=GetNoGitPagerEnv(),
98 stdout=subprocess2.PIPE,
99 stderr=stderr)
100 return code, out[0]
101 except ValueError:
102 # When the subprocess fails, it returns None. That triggers a ValueError
103 # when trying to unpack the return value into (out, code).
104 return 1, ''
105
106
107 def IsGitVersionAtLeast(min_version):
108 prefix = 'git version '
109 version = RunGit(['--version']).strip()
110 return (version.startswith(prefix) and
111 LooseVersion(version[len(prefix):]) >= LooseVersion(min_version))
112
113
114 def ask_for_data(prompt):
115 try:
116 return raw_input(prompt)
117 except KeyboardInterrupt:
118 # Hide the exception.
119 sys.exit(1)
120
121
122 def git_set_branch_value(key, value):
123 branch = Changelist().GetBranch()
124 if not branch:
125 return
126
127 cmd = ['config']
128 if isinstance(value, int):
129 cmd.append('--int')
130 git_key = 'branch.%s.%s' % (branch, key)
131 RunGit(cmd + [git_key, str(value)])
132
133
134 def git_get_branch_default(key, default):
135 branch = Changelist().GetBranch()
136 if branch:
137 git_key = 'branch.%s.%s' % (branch, key)
138 (_, stdout) = RunGitWithCode(['config', '--int', '--get', git_key])
139 try:
140 return int(stdout.strip())
141 except ValueError:
142 pass
143 return default
144
145
146 def add_git_similarity(parser):
147 parser.add_option(
148 '--similarity', metavar='SIM', type='int', action='store',
149 help='Sets the percentage that a pair of files need to match in order to'
150 ' be considered copies (default 50)')
151 parser.add_option(
152 '--find-copies', action='store_true',
153 help='Allows git to look for copies.')
154 parser.add_option(
155 '--no-find-copies', action='store_false', dest='find_copies',
156 help='Disallows git from looking for copies.')
157
158 old_parser_args = parser.parse_args
159 def Parse(args):
160 options, args = old_parser_args(args)
161
162 if options.similarity is None:
163 options.similarity = git_get_branch_default('git-cl-similarity', 50)
164 else:
165 print('Note: Saving similarity of %d%% in git config.'
166 % options.similarity)
167 git_set_branch_value('git-cl-similarity', options.similarity)
168
169 options.similarity = max(0, min(options.similarity, 100))
170
171 if options.find_copies is None:
172 options.find_copies = bool(
173 git_get_branch_default('git-find-copies', True))
174 else:
175 git_set_branch_value('git-find-copies', int(options.find_copies))
176
177 print('Using %d%% similarity for rename/copy detection. '
178 'Override with --similarity.' % options.similarity)
179
180 return options, args
181 parser.parse_args = Parse
182
183
184 def is_dirty_git_tree(cmd):
185 # Make sure index is up-to-date before running diff-index.
186 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
187 dirty = RunGit(['diff-index', '--name-status', 'HEAD'])
188 if dirty:
189 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
190 print 'Uncommitted files: (git diff-index --name-status HEAD)'
191 print dirty[:4096]
192 if len(dirty) > 4096:
193 print '... (run "git diff-index --name-status HEAD" to see full output).'
194 return True
195 return False
196
197
198 def MatchSvnGlob(url, base_url, glob_spec, allow_wildcards):
199 """Return the corresponding git ref if |base_url| together with |glob_spec|
200 matches the full |url|.
201
202 If |allow_wildcards| is true, |glob_spec| can contain wildcards (see below).
203 """
204 fetch_suburl, as_ref = glob_spec.split(':')
205 if allow_wildcards:
206 glob_match = re.match('(.+/)?(\*|{[^/]*})(/.+)?', fetch_suburl)
207 if glob_match:
208 # Parse specs like "branches/*/src:refs/remotes/svn/*" or
209 # "branches/{472,597,648}/src:refs/remotes/svn/*".
210 branch_re = re.escape(base_url)
211 if glob_match.group(1):
212 branch_re += '/' + re.escape(glob_match.group(1))
213 wildcard = glob_match.group(2)
214 if wildcard == '*':
215 branch_re += '([^/]*)'
216 else:
217 # Escape and replace surrounding braces with parentheses and commas
218 # with pipe symbols.
219 wildcard = re.escape(wildcard)
220 wildcard = re.sub('^\\\\{', '(', wildcard)
221 wildcard = re.sub('\\\\,', '|', wildcard)
222 wildcard = re.sub('\\\\}$', ')', wildcard)
223 branch_re += wildcard
224 if glob_match.group(3):
225 branch_re += re.escape(glob_match.group(3))
226 match = re.match(branch_re, url)
227 if match:
228 return re.sub('\*$', match.group(1), as_ref)
229
230 # Parse specs like "trunk/src:refs/remotes/origin/trunk".
231 if fetch_suburl:
232 full_url = base_url + '/' + fetch_suburl
233 else:
234 full_url = base_url
235 if full_url == url:
236 return as_ref
237 return None
238
239
240 def print_stats(similarity, find_copies, args):
241 """Prints statistics about the change to the user."""
242 # --no-ext-diff is broken in some versions of Git, so try to work around
243 # this by overriding the environment (but there is still a problem if the
244 # git config key "diff.external" is used).
245 env = GetNoGitPagerEnv()
246 if 'GIT_EXTERNAL_DIFF' in env:
247 del env['GIT_EXTERNAL_DIFF']
248
249 if find_copies:
250 similarity_options = ['--find-copies-harder', '-l100000',
251 '-C%s' % similarity]
252 else:
253 similarity_options = ['-M%s' % similarity]
254
255 return subprocess2.call(
256 ['git',
257 'diff', '--no-ext-diff', '--stat'] + similarity_options + args,
258 env=env)
259
260
261 class Settings(object):
262 def __init__(self):
263 self.default_server = None
264 self.cc = None
265 self.root = None
266 self.is_git_svn = None
267 self.svn_branch = None
268 self.tree_status_url = None
269 self.viewvc_url = None
270 self.updated = False
271 self.is_gerrit = None
272 self.git_editor = None
273
274 def LazyUpdateIfNeeded(self):
275 """Updates the settings from a codereview.settings file, if available."""
276 if not self.updated:
277 # The only value that actually changes the behavior is
278 # autoupdate = "false". Everything else means "true".
279 autoupdate = RunGit(['config', 'rietveld.autoupdate'],
280 error_ok=True
281 ).strip().lower()
282
283 cr_settings_file = FindCodereviewSettingsFile()
284 if autoupdate != 'false' and cr_settings_file:
285 LoadCodereviewSettingsFromFile(cr_settings_file)
286 # set updated to True to avoid infinite calling loop
287 # through DownloadHooks
288 self.updated = True
289 DownloadHooks(False)
290 self.updated = True
291
292 def GetDefaultServerUrl(self, error_ok=False):
293 if not self.default_server:
294 self.LazyUpdateIfNeeded()
295 self.default_server = gclient_utils.UpgradeToHttps(
296 self._GetRietveldConfig('server', error_ok=True))
297 if error_ok:
298 return self.default_server
299 if not self.default_server:
300 error_message = ('Could not find settings file. You must configure '
301 'your review setup by running "git cl config".')
302 self.default_server = gclient_utils.UpgradeToHttps(
303 self._GetRietveldConfig('server', error_message=error_message))
304 return self.default_server
305
306 @staticmethod
307 def GetRelativeRoot():
308 return RunGit(['rev-parse', '--show-cdup']).strip()
309
310 def GetRoot(self):
311 if self.root is None:
312 self.root = os.path.abspath(self.GetRelativeRoot())
313 return self.root
314
315 def GetIsGitSvn(self):
316 """Return true if this repo looks like it's using git-svn."""
317 if self.is_git_svn is None:
318 # If you have any "svn-remote.*" config keys, we think you're using svn.
319 self.is_git_svn = RunGitWithCode(
320 ['config', '--local', '--get-regexp', r'^svn-remote\.'])[0] == 0
321 return self.is_git_svn
322
323 def GetSVNBranch(self):
324 if self.svn_branch is None:
325 if not self.GetIsGitSvn():
326 DieWithError('Repo doesn\'t appear to be a git-svn repo.')
327
328 # Try to figure out which remote branch we're based on.
329 # Strategy:
330 # 1) iterate through our branch history and find the svn URL.
331 # 2) find the svn-remote that fetches from the URL.
332
333 # regexp matching the git-svn line that contains the URL.
334 git_svn_re = re.compile(r'^\s*git-svn-id: (\S+)@', re.MULTILINE)
335
336 # We don't want to go through all of history, so read a line from the
337 # pipe at a time.
338 # The -100 is an arbitrary limit so we don't search forever.
339 cmd = ['git', 'log', '-100', '--pretty=medium']
340 proc = subprocess2.Popen(cmd, stdout=subprocess2.PIPE,
341 env=GetNoGitPagerEnv())
342 url = None
343 for line in proc.stdout:
344 match = git_svn_re.match(line)
345 if match:
346 url = match.group(1)
347 proc.stdout.close() # Cut pipe.
348 break
349
350 if url:
351 svn_remote_re = re.compile(r'^svn-remote\.([^.]+)\.url (.*)$')
352 remotes = RunGit(['config', '--get-regexp',
353 r'^svn-remote\..*\.url']).splitlines()
354 for remote in remotes:
355 match = svn_remote_re.match(remote)
356 if match:
357 remote = match.group(1)
358 base_url = match.group(2)
359 rewrite_root = RunGit(
360 ['config', 'svn-remote.%s.rewriteRoot' % remote],
361 error_ok=True).strip()
362 if rewrite_root:
363 base_url = rewrite_root
364 fetch_spec = RunGit(
365 ['config', 'svn-remote.%s.fetch' % remote],
366 error_ok=True).strip()
367 if fetch_spec:
368 self.svn_branch = MatchSvnGlob(url, base_url, fetch_spec, False)
369 if self.svn_branch:
370 break
371 branch_spec = RunGit(
372 ['config', 'svn-remote.%s.branches' % remote],
373 error_ok=True).strip()
374 if branch_spec:
375 self.svn_branch = MatchSvnGlob(url, base_url, branch_spec, True)
376 if self.svn_branch:
377 break
378 tag_spec = RunGit(
379 ['config', 'svn-remote.%s.tags' % remote],
380 error_ok=True).strip()
381 if tag_spec:
382 self.svn_branch = MatchSvnGlob(url, base_url, tag_spec, True)
383 if self.svn_branch:
384 break
385
386 if not self.svn_branch:
387 DieWithError('Can\'t guess svn branch -- try specifying it on the '
388 'command line')
389
390 return self.svn_branch
391
392 def GetTreeStatusUrl(self, error_ok=False):
393 if not self.tree_status_url:
394 error_message = ('You must configure your tree status URL by running '
395 '"git cl config".')
396 self.tree_status_url = self._GetRietveldConfig(
397 'tree-status-url', error_ok=error_ok, error_message=error_message)
398 return self.tree_status_url
399
400 def GetViewVCUrl(self):
401 if not self.viewvc_url:
402 self.viewvc_url = self._GetRietveldConfig('viewvc-url', error_ok=True)
403 return self.viewvc_url
404
405 def GetBugPrefix(self):
406 return self._GetRietveldConfig('bug-prefix', error_ok=True)
407
408 def GetDefaultCCList(self):
409 return self._GetRietveldConfig('cc', error_ok=True)
410
411 def GetDefaultPrivateFlag(self):
412 return self._GetRietveldConfig('private', error_ok=True)
413
414 def GetIsGerrit(self):
415 """Return true if this repo is assosiated with gerrit code review system."""
416 if self.is_gerrit is None:
417 self.is_gerrit = self._GetConfig('gerrit.host', error_ok=True)
418 return self.is_gerrit
419
420 def GetGitEditor(self):
421 """Return the editor specified in the git config, or None if none is."""
422 if self.git_editor is None:
423 self.git_editor = self._GetConfig('core.editor', error_ok=True)
424 return self.git_editor or None
425
426 def _GetRietveldConfig(self, param, **kwargs):
427 return self._GetConfig('rietveld.' + param, **kwargs)
428
429 def _GetConfig(self, param, **kwargs):
430 self.LazyUpdateIfNeeded()
431 return RunGit(['config', param], **kwargs).strip()
432
433
434 def ShortBranchName(branch):
435 """Convert a name like 'refs/heads/foo' to just 'foo'."""
436 return branch.replace('refs/heads/', '')
437
438
439 class Changelist(object):
440 def __init__(self, branchref=None, issue=None):
441 # Poke settings so we get the "configure your server" message if necessary.
442 global settings
443 if not settings:
444 # Happens when git_cl.py is used as a utility library.
445 settings = Settings()
446 settings.GetDefaultServerUrl()
447 self.branchref = branchref
448 if self.branchref:
449 self.branch = ShortBranchName(self.branchref)
450 else:
451 self.branch = None
452 self.rietveld_server = None
453 self.upstream_branch = None
454 self.lookedup_issue = False
455 self.issue = issue or None
456 self.has_description = False
457 self.description = None
458 self.lookedup_patchset = False
459 self.patchset = None
460 self._rpc_server = None
461 self.cc = None
462 self.watchers = ()
463 self._remote = None
464 self._props = None
465
466 def GetCCList(self):
467 """Return the users cc'd on this CL.
468
469 Return is a string suitable for passing to gcl with the --cc flag.
470 """
471 if self.cc is None:
472 base_cc = settings.GetDefaultCCList()
473 more_cc = ','.join(self.watchers)
474 self.cc = ','.join(filter(None, (base_cc, more_cc))) or ''
475 return self.cc
476
477 def GetCCListWithoutDefault(self):
478 """Return the users cc'd on this CL excluding default ones."""
479 if self.cc is None:
480 self.cc = ','.join(self.watchers)
481 return self.cc
482
483 def SetWatchers(self, watchers):
484 """Set the list of email addresses that should be cc'd based on the changed
485 files in this CL.
486 """
487 self.watchers = watchers
488
489 def GetBranch(self):
490 """Returns the short branch name, e.g. 'master'."""
491 if not self.branch:
492 self.branchref = RunGit(['symbolic-ref', 'HEAD']).strip()
493 self.branch = ShortBranchName(self.branchref)
494 return self.branch
495
496 def GetBranchRef(self):
497 """Returns the full branch name, e.g. 'refs/heads/master'."""
498 self.GetBranch() # Poke the lazy loader.
499 return self.branchref
500
501 @staticmethod
502 def FetchUpstreamTuple(branch):
503 """Returns a tuple containing remote and remote ref,
504 e.g. 'origin', 'refs/heads/master'
505 """
506 remote = '.'
507 upstream_branch = RunGit(['config', 'branch.%s.merge' % branch],
508 error_ok=True).strip()
509 if upstream_branch:
510 remote = RunGit(['config', 'branch.%s.remote' % branch]).strip()
511 else:
512 upstream_branch = RunGit(['config', 'rietveld.upstream-branch'],
513 error_ok=True).strip()
514 if upstream_branch:
515 remote = RunGit(['config', 'rietveld.upstream-remote']).strip()
516 else:
517 # Fall back on trying a git-svn upstream branch.
518 if settings.GetIsGitSvn():
519 upstream_branch = settings.GetSVNBranch()
520 else:
521 # Else, try to guess the origin remote.
522 remote_branches = RunGit(['branch', '-r']).split()
523 if 'origin/master' in remote_branches:
524 # Fall back on origin/master if it exits.
525 remote = 'origin'
526 upstream_branch = 'refs/heads/master'
527 elif 'origin/trunk' in remote_branches:
528 # Fall back on origin/trunk if it exists. Generally a shared
529 # git-svn clone
530 remote = 'origin'
531 upstream_branch = 'refs/heads/trunk'
532 else:
533 DieWithError("""Unable to determine default branch to diff against.
534 Either pass complete "git diff"-style arguments, like
535 git cl upload origin/master
536 or verify this branch is set up to track another (via the --track argument to
537 "git checkout -b ...").""")
538
539 return remote, upstream_branch
540
541 def GetCommonAncestorWithUpstream(self):
542 return RunGit(['merge-base', self.GetUpstreamBranch(), 'HEAD']).strip()
543
544 def GetUpstreamBranch(self):
545 if self.upstream_branch is None:
546 remote, upstream_branch = self.FetchUpstreamTuple(self.GetBranch())
547 if remote is not '.':
548 upstream_branch = upstream_branch.replace('heads', 'remotes/' + remote)
549 self.upstream_branch = upstream_branch
550 return self.upstream_branch
551
552 def GetRemoteBranch(self):
553 if not self._remote:
554 remote, branch = None, self.GetBranch()
555 seen_branches = set()
556 while branch not in seen_branches:
557 seen_branches.add(branch)
558 remote, branch = self.FetchUpstreamTuple(branch)
559 branch = ShortBranchName(branch)
560 if remote != '.' or branch.startswith('refs/remotes'):
561 break
562 else:
563 remotes = RunGit(['remote'], error_ok=True).split()
564 if len(remotes) == 1:
565 remote, = remotes
566 elif 'origin' in remotes:
567 remote = 'origin'
568 logging.warning('Could not determine which remote this change is '
569 'associated with, so defaulting to "%s". This may '
570 'not be what you want. You may prevent this message '
571 'by running "git svn info" as documented here: %s',
572 self._remote,
573 GIT_INSTRUCTIONS_URL)
574 else:
575 logging.warn('Could not determine which remote this change is '
576 'associated with. You may prevent this message by '
577 'running "git svn info" as documented here: %s',
578 GIT_INSTRUCTIONS_URL)
579 branch = 'HEAD'
580 if branch.startswith('refs/remotes'):
581 self._remote = (remote, branch)
582 else:
583 self._remote = (remote, 'refs/remotes/%s/%s' % (remote, branch))
584 return self._remote
585
586 def GitSanityChecks(self, upstream_git_obj):
587 """Checks git repo status and ensures diff is from local commits."""
588
589 # Verify the commit we're diffing against is in our current branch.
590 upstream_sha = RunGit(['rev-parse', '--verify', upstream_git_obj]).strip()
591 common_ancestor = RunGit(['merge-base', upstream_sha, 'HEAD']).strip()
592 if upstream_sha != common_ancestor:
593 print >> sys.stderr, (
594 'ERROR: %s is not in the current branch. You may need to rebase '
595 'your tracking branch' % upstream_sha)
596 return False
597
598 # List the commits inside the diff, and verify they are all local.
599 commits_in_diff = RunGit(
600 ['rev-list', '^%s' % upstream_sha, 'HEAD']).splitlines()
601 code, remote_branch = RunGitWithCode(['config', 'gitcl.remotebranch'])
602 remote_branch = remote_branch.strip()
603 if code != 0:
604 _, remote_branch = self.GetRemoteBranch()
605
606 commits_in_remote = RunGit(
607 ['rev-list', '^%s' % upstream_sha, remote_branch]).splitlines()
608
609 common_commits = set(commits_in_diff) & set(commits_in_remote)
610 if common_commits:
611 print >> sys.stderr, (
612 'ERROR: Your diff contains %d commits already in %s.\n'
613 'Run "git log --oneline %s..HEAD" to get a list of commits in '
614 'the diff. If you are using a custom git flow, you can override'
615 ' the reference used for this check with "git config '
616 'gitcl.remotebranch <git-ref>".' % (
617 len(common_commits), remote_branch, upstream_git_obj))
618 return False
619 return True
620
621 def GetGitBaseUrlFromConfig(self):
622 """Return the configured base URL from branch.<branchname>.baseurl.
623
624 Returns None if it is not set.
625 """
626 return RunGit(['config', 'branch.%s.base-url' % self.GetBranch()],
627 error_ok=True).strip()
628
629 def GetRemoteUrl(self):
630 """Return the configured remote URL, e.g. 'git://example.org/foo.git/'.
631
632 Returns None if there is no remote.
633 """
634 remote, _ = self.GetRemoteBranch()
635 return RunGit(['config', 'remote.%s.url' % remote], error_ok=True).strip()
636
637 def GetIssue(self):
638 """Returns the issue number as a int or None if not set."""
639 if self.issue is None and not self.lookedup_issue:
640 issue = RunGit(['config', self._IssueSetting()], error_ok=True).strip()
641 self.issue = int(issue) or None if issue else None
642 self.lookedup_issue = True
643 return self.issue
644
645 def GetRietveldServer(self):
646 if not self.rietveld_server:
647 # If we're on a branch then get the server potentially associated
648 # with that branch.
649 if self.GetIssue():
650 self.rietveld_server = gclient_utils.UpgradeToHttps(RunGit(
651 ['config', self._RietveldServer()], error_ok=True).strip())
652 if not self.rietveld_server:
653 self.rietveld_server = settings.GetDefaultServerUrl()
654 return self.rietveld_server
655
656 def GetIssueURL(self):
657 """Get the URL for a particular issue."""
658 if not self.GetIssue():
659 return None
660 return '%s/%s' % (self.GetRietveldServer(), self.GetIssue())
661
662 def GetDescription(self, pretty=False):
663 if not self.has_description:
664 if self.GetIssue():
665 issue = self.GetIssue()
666 try:
667 self.description = self.RpcServer().get_description(issue).strip()
668 except urllib2.HTTPError, e:
669 if e.code == 404:
670 DieWithError(
671 ('\nWhile fetching the description for issue %d, received a '
672 '404 (not found)\n'
673 'error. It is likely that you deleted this '
674 'issue on the server. If this is the\n'
675 'case, please run\n\n'
676 ' git cl issue 0\n\n'
677 'to clear the association with the deleted issue. Then run '
678 'this command again.') % issue)
679 else:
680 DieWithError(
681 '\nFailed to fetch issue description. HTTP error %d' % e.code)
682 self.has_description = True
683 if pretty:
684 wrapper = textwrap.TextWrapper()
685 wrapper.initial_indent = wrapper.subsequent_indent = ' '
686 return wrapper.fill(self.description)
687 return self.description
688
689 def GetPatchset(self):
690 """Returns the patchset number as a int or None if not set."""
691 if self.patchset is None and not self.lookedup_patchset:
692 patchset = RunGit(['config', self._PatchsetSetting()],
693 error_ok=True).strip()
694 self.patchset = int(patchset) or None if patchset else None
695 self.lookedup_patchset = True
696 return self.patchset
697
698 def SetPatchset(self, patchset):
699 """Set this branch's patchset. If patchset=0, clears the patchset."""
700 if patchset:
701 RunGit(['config', self._PatchsetSetting(), str(patchset)])
702 self.patchset = patchset
703 else:
704 RunGit(['config', '--unset', self._PatchsetSetting()],
705 stderr=subprocess2.PIPE, error_ok=True)
706 self.patchset = None
707
708 def GetMostRecentPatchset(self):
709 return self.GetIssueProperties()['patchsets'][-1]
710
711 def GetPatchSetDiff(self, issue, patchset):
712 return self.RpcServer().get(
713 '/download/issue%s_%s.diff' % (issue, patchset))
714
715 def GetIssueProperties(self):
716 if self._props is None:
717 issue = self.GetIssue()
718 if not issue:
719 self._props = {}
720 else:
721 self._props = self.RpcServer().get_issue_properties(issue, True)
722 return self._props
723
724 def GetApprovingReviewers(self):
725 return get_approving_reviewers(self.GetIssueProperties())
726
727 def SetIssue(self, issue):
728 """Set this branch's issue. If issue=0, clears the issue."""
729 if issue:
730 self.issue = issue
731 RunGit(['config', self._IssueSetting(), str(issue)])
732 if self.rietveld_server:
733 RunGit(['config', self._RietveldServer(), self.rietveld_server])
734 else:
735 current_issue = self.GetIssue()
736 if current_issue:
737 RunGit(['config', '--unset', self._IssueSetting()])
738 self.issue = None
739 self.SetPatchset(None)
740
741 def GetChange(self, upstream_branch, author):
742 if not self.GitSanityChecks(upstream_branch):
743 DieWithError('\nGit sanity check failure')
744
745 root = settings.GetRelativeRoot()
746 if not root:
747 root = '.'
748 absroot = os.path.abspath(root)
749
750 # We use the sha1 of HEAD as a name of this change.
751 name = RunGitWithCode(['rev-parse', 'HEAD'])[1].strip()
752 # Need to pass a relative path for msysgit.
753 try:
754 files = scm.GIT.CaptureStatus([root], '.', upstream_branch)
755 except subprocess2.CalledProcessError:
756 DieWithError(
757 ('\nFailed to diff against upstream branch %s\n\n'
758 'This branch probably doesn\'t exist anymore. To reset the\n'
759 'tracking branch, please run\n'
760 ' git branch --set-upstream %s trunk\n'
761 'replacing trunk with origin/master or the relevant branch') %
762 (upstream_branch, self.GetBranch()))
763
764 issue = self.GetIssue()
765 patchset = self.GetPatchset()
766 if issue:
767 description = self.GetDescription()
768 else:
769 # If the change was never uploaded, use the log messages of all commits
770 # up to the branch point, as git cl upload will prefill the description
771 # with these log messages.
772 args = ['log', '--pretty=format:%s%n%n%b', '%s...' % (upstream_branch)]
773 description = RunGitWithCode(args)[1].strip()
774
775 if not author:
776 author = RunGit(['config', 'user.email']).strip() or None
777 return presubmit_support.GitChange(
778 name,
779 description,
780 absroot,
781 files,
782 issue,
783 patchset,
784 author)
785
786 def RunHook(self, committing, may_prompt, verbose, change):
787 """Calls sys.exit() if the hook fails; returns a HookResults otherwise."""
788
789 try:
790 return presubmit_support.DoPresubmitChecks(change, committing,
791 verbose=verbose, output_stream=sys.stdout, input_stream=sys.stdin,
792 default_presubmit=None, may_prompt=may_prompt,
793 rietveld_obj=self.RpcServer())
794 except presubmit_support.PresubmitFailure, e:
795 DieWithError(
796 ('%s\nMaybe your depot_tools is out of date?\n'
797 'If all fails, contact maruel@') % e)
798
799 def UpdateDescription(self, description):
800 self.description = description
801 return self.RpcServer().update_description(
802 self.GetIssue(), self.description)
803
804 def CloseIssue(self):
805 """Updates the description and closes the issue."""
806 return self.RpcServer().close_issue(self.GetIssue())
807
808 def SetFlag(self, flag, value):
809 """Patchset must match."""
810 if not self.GetPatchset():
811 DieWithError('The patchset needs to match. Send another patchset.')
812 try:
813 return self.RpcServer().set_flag(
814 self.GetIssue(), self.GetPatchset(), flag, value)
815 except urllib2.HTTPError, e:
816 if e.code == 404:
817 DieWithError('The issue %s doesn\'t exist.' % self.GetIssue())
818 if e.code == 403:
819 DieWithError(
820 ('Access denied to issue %s. Maybe the patchset %s doesn\'t '
821 'match?') % (self.GetIssue(), self.GetPatchset()))
822 raise
823
824 def RpcServer(self):
825 """Returns an upload.RpcServer() to access this review's rietveld instance.
826 """
827 if not self._rpc_server:
828 self._rpc_server = rietveld.CachingRietveld(
829 self.GetRietveldServer(), None, None)
830 return self._rpc_server
831
832 def _IssueSetting(self):
833 """Return the git setting that stores this change's issue."""
834 return 'branch.%s.rietveldissue' % self.GetBranch()
835
836 def _PatchsetSetting(self):
837 """Return the git setting that stores this change's most recent patchset."""
838 return 'branch.%s.rietveldpatchset' % self.GetBranch()
839
840 def _RietveldServer(self):
841 """Returns the git setting that stores this change's rietveld server."""
842 return 'branch.%s.rietveldserver' % self.GetBranch()
843
844
845 def GetCodereviewSettingsInteractively():
846 """Prompt the user for settings."""
847 # TODO(ukai): ask code review system is rietveld or gerrit?
848 server = settings.GetDefaultServerUrl(error_ok=True)
849 prompt = 'Rietveld server (host[:port])'
850 prompt += ' [%s]' % (server or DEFAULT_SERVER)
851 newserver = ask_for_data(prompt + ':')
852 if not server and not newserver:
853 newserver = DEFAULT_SERVER
854 if newserver:
855 newserver = gclient_utils.UpgradeToHttps(newserver)
856 if newserver != server:
857 RunGit(['config', 'rietveld.server', newserver])
858
859 def SetProperty(initial, caption, name, is_url):
860 prompt = caption
861 if initial:
862 prompt += ' ("x" to clear) [%s]' % initial
863 new_val = ask_for_data(prompt + ':')
864 if new_val == 'x':
865 RunGit(['config', '--unset-all', 'rietveld.' + name], error_ok=True)
866 elif new_val:
867 if is_url:
868 new_val = gclient_utils.UpgradeToHttps(new_val)
869 if new_val != initial:
870 RunGit(['config', 'rietveld.' + name, new_val])
871
872 SetProperty(settings.GetDefaultCCList(), 'CC list', 'cc', False)
873 SetProperty(settings.GetDefaultPrivateFlag(),
874 'Private flag (rietveld only)', 'private', False)
875 SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL',
876 'tree-status-url', False)
877 SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url', True)
878 SetProperty(settings.GetBugPrefix(), 'Bug Prefix', 'bug-prefix', False)
879
880 # TODO: configure a default branch to diff against, rather than this
881 # svn-based hackery.
882
883
884 class ChangeDescription(object):
885 """Contains a parsed form of the change description."""
886 R_LINE = r'^[ \t]*(TBR|R)[ \t]*=[ \t]*(.*?)[ \t]*$'
887 BUG_LINE = r'^[ \t]*(BUG)[ \t]*=[ \t]*(.*?)[ \t]*$'
888
889 def __init__(self, description):
890 self._description_lines = (description or '').strip().splitlines()
891
892 @property # www.logilab.org/ticket/89786
893 def description(self): # pylint: disable=E0202
894 return '\n'.join(self._description_lines)
895
896 def set_description(self, desc):
897 if isinstance(desc, basestring):
898 lines = desc.splitlines()
899 else:
900 lines = [line.rstrip() for line in desc]
901 while lines and not lines[0]:
902 lines.pop(0)
903 while lines and not lines[-1]:
904 lines.pop(-1)
905 self._description_lines = lines
906
907 def update_reviewers(self, reviewers):
908 """Rewrites the R=/TBR= line(s) as a single line each."""
909 assert isinstance(reviewers, list), reviewers
910 if not reviewers:
911 return
912 reviewers = reviewers[:]
913
914 # Get the set of R= and TBR= lines and remove them from the desciption.
915 regexp = re.compile(self.R_LINE)
916 matches = [regexp.match(line) for line in self._description_lines]
917 new_desc = [l for i, l in enumerate(self._description_lines)
918 if not matches[i]]
919 self.set_description(new_desc)
920
921 # Construct new unified R= and TBR= lines.
922 r_names = []
923 tbr_names = []
924 for match in matches:
925 if not match:
926 continue
927 people = cleanup_list([match.group(2).strip()])
928 if match.group(1) == 'TBR':
929 tbr_names.extend(people)
930 else:
931 r_names.extend(people)
932 for name in r_names:
933 if name not in reviewers:
934 reviewers.append(name)
935 new_r_line = 'R=' + ', '.join(reviewers) if reviewers else None
936 new_tbr_line = 'TBR=' + ', '.join(tbr_names) if tbr_names else None
937
938 # Put the new lines in the description where the old first R= line was.
939 line_loc = next((i for i, match in enumerate(matches) if match), -1)
940 if 0 <= line_loc < len(self._description_lines):
941 if new_tbr_line:
942 self._description_lines.insert(line_loc, new_tbr_line)
943 if new_r_line:
944 self._description_lines.insert(line_loc, new_r_line)
945 else:
946 if new_r_line:
947 self.append_footer(new_r_line)
948 if new_tbr_line:
949 self.append_footer(new_tbr_line)
950
951 def prompt(self):
952 """Asks the user to update the description."""
953 self.set_description([
954 '# Enter a description of the change.',
955 '# This will be displayed on the codereview site.',
956 '# The first line will also be used as the subject of the review.',
957 '#--------------------This line is 72 characters long'
958 '--------------------',
959 ] + self._description_lines)
960
961 regexp = re.compile(self.BUG_LINE)
962 if not any((regexp.match(line) for line in self._description_lines)):
963 self.append_footer('BUG=%s' % settings.GetBugPrefix())
964 content = gclient_utils.RunEditor(self.description, True,
965 git_editor=settings.GetGitEditor())
966 if not content:
967 DieWithError('Running editor failed')
968 lines = content.splitlines()
969
970 # Strip off comments.
971 clean_lines = [line.rstrip() for line in lines if not line.startswith('#')]
972 if not clean_lines:
973 DieWithError('No CL description, aborting')
974 self.set_description(clean_lines)
975
976 def append_footer(self, line):
977 if self._description_lines:
978 # Add an empty line if either the last line or the new line isn't a tag.
979 last_line = self._description_lines[-1]
980 if (not presubmit_support.Change.TAG_LINE_RE.match(last_line) or
981 not presubmit_support.Change.TAG_LINE_RE.match(line)):
982 self._description_lines.append('')
983 self._description_lines.append(line)
984
985 def get_reviewers(self):
986 """Retrieves the list of reviewers."""
987 matches = [re.match(self.R_LINE, line) for line in self._description_lines]
988 reviewers = [match.group(2).strip() for match in matches if match]
989 return cleanup_list(reviewers)
990
991
992 def get_approving_reviewers(props):
993 """Retrieves the reviewers that approved a CL from the issue properties with
994 messages.
995
996 Note that the list may contain reviewers that are not committer, thus are not
997 considered by the CQ.
998 """
999 return sorted(
1000 set(
1001 message['sender']
1002 for message in props['messages']
1003 if message['approval'] and message['sender'] in props['reviewers']
1004 )
1005 )
1006
1007
1008 def FindCodereviewSettingsFile(filename='codereview.settings'):
1009 """Finds the given file starting in the cwd and going up.
1010
1011 Only looks up to the top of the repository unless an
1012 'inherit-review-settings-ok' file exists in the root of the repository.
1013 """
1014 inherit_ok_file = 'inherit-review-settings-ok'
1015 cwd = os.getcwd()
1016 root = settings.GetRoot()
1017 if os.path.isfile(os.path.join(root, inherit_ok_file)):
1018 root = '/'
1019 while True:
1020 if filename in os.listdir(cwd):
1021 if os.path.isfile(os.path.join(cwd, filename)):
1022 return open(os.path.join(cwd, filename))
1023 if cwd == root:
1024 break
1025 cwd = os.path.dirname(cwd)
1026
1027
1028 def LoadCodereviewSettingsFromFile(fileobj):
1029 """Parse a codereview.settings file and updates hooks."""
1030 keyvals = gclient_utils.ParseCodereviewSettingsContent(fileobj.read())
1031
1032 def SetProperty(name, setting, unset_error_ok=False):
1033 fullname = 'rietveld.' + name
1034 if setting in keyvals:
1035 RunGit(['config', fullname, keyvals[setting]])
1036 else:
1037 RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
1038
1039 SetProperty('server', 'CODE_REVIEW_SERVER')
1040 # Only server setting is required. Other settings can be absent.
1041 # In that case, we ignore errors raised during option deletion attempt.
1042 SetProperty('cc', 'CC_LIST', unset_error_ok=True)
1043 SetProperty('private', 'PRIVATE', unset_error_ok=True)
1044 SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
1045 SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
1046 SetProperty('bug-prefix', 'BUG_PREFIX', unset_error_ok=True)
1047
1048 if 'GERRIT_HOST' in keyvals:
1049 RunGit(['config', 'gerrit.host', keyvals['GERRIT_HOST']])
1050
1051 if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
1052 #should be of the form
1053 #PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
1054 #ORIGIN_URL_CONFIG: http://src.chromium.org/git
1055 RunGit(['config', keyvals['PUSH_URL_CONFIG'],
1056 keyvals['ORIGIN_URL_CONFIG']])
1057
1058
1059 def urlretrieve(source, destination):
1060 """urllib is broken for SSL connections via a proxy therefore we
1061 can't use urllib.urlretrieve()."""
1062 with open(destination, 'w') as f:
1063 f.write(urllib2.urlopen(source).read())
1064
1065
1066 def hasSheBang(fname):
1067 """Checks fname is a #! script."""
1068 with open(fname) as f:
1069 return f.read(2).startswith('#!')
1070
1071
1072 def DownloadHooks(force):
1073 """downloads hooks
1074
1075 Args:
1076 force: True to update hooks. False to install hooks if not present.
1077 """
1078 if not settings.GetIsGerrit():
1079 return
1080 src = 'https://gerrit-review.googlesource.com/tools/hooks/commit-msg'
1081 dst = os.path.join(settings.GetRoot(), '.git', 'hooks', 'commit-msg')
1082 if not os.access(dst, os.X_OK):
1083 if os.path.exists(dst):
1084 if not force:
1085 return
1086 try:
1087 urlretrieve(src, dst)
1088 if not hasSheBang(dst):
1089 DieWithError('Not a script: %s\n'
1090 'You need to download from\n%s\n'
1091 'into .git/hooks/commit-msg and '
1092 'chmod +x .git/hooks/commit-msg' % (dst, src))
1093 os.chmod(dst, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
1094 except Exception:
1095 if os.path.exists(dst):
1096 os.remove(dst)
1097 DieWithError('\nFailed to download hooks.\n'
1098 'You need to download from\n%s\n'
1099 'into .git/hooks/commit-msg and '
1100 'chmod +x .git/hooks/commit-msg' % src)
1101
1102
1103 @subcommand.usage('[repo root containing codereview.settings]')
1104 def CMDconfig(parser, args):
1105 """Edits configuration for this tree."""
1106
1107 parser.add_option('--activate-update', action='store_true',
1108 help='activate auto-updating [rietveld] section in '
1109 '.git/config')
1110 parser.add_option('--deactivate-update', action='store_true',
1111 help='deactivate auto-updating [rietveld] section in '
1112 '.git/config')
1113 options, args = parser.parse_args(args)
1114
1115 if options.deactivate_update:
1116 RunGit(['config', 'rietveld.autoupdate', 'false'])
1117 return
1118
1119 if options.activate_update:
1120 RunGit(['config', '--unset', 'rietveld.autoupdate'])
1121 return
1122
1123 if len(args) == 0:
1124 GetCodereviewSettingsInteractively()
1125 DownloadHooks(True)
1126 return 0
1127
1128 url = args[0]
1129 if not url.endswith('codereview.settings'):
1130 url = os.path.join(url, 'codereview.settings')
1131
1132 # Load code review settings and download hooks (if available).
1133 LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
1134 DownloadHooks(True)
1135 return 0
1136
1137
1138 def CMDbaseurl(parser, args):
1139 """Gets or sets base-url for this branch."""
1140 branchref = RunGit(['symbolic-ref', 'HEAD']).strip()
1141 branch = ShortBranchName(branchref)
1142 _, args = parser.parse_args(args)
1143 if not args:
1144 print("Current base-url:")
1145 return RunGit(['config', 'branch.%s.base-url' % branch],
1146 error_ok=False).strip()
1147 else:
1148 print("Setting base-url to %s" % args[0])
1149 return RunGit(['config', 'branch.%s.base-url' % branch, args[0]],
1150 error_ok=False).strip()
1151
1152
1153 def CMDstatus(parser, args):
1154 """Show status of changelists.
1155
1156 Colors are used to tell the state of the CL unless --fast is used:
1157 - Red not sent for review or broken
1158 - Blue waiting for review
1159 - Yellow waiting for you to reply to review
1160 - Green LGTM'ed
1161 - Magenta in the commit queue
1162 - Cyan was committed, branch can be deleted
1163
1164 Also see 'git cl comments'.
1165 """
1166 parser.add_option('--field',
1167 help='print only specific field (desc|id|patch|url)')
1168 parser.add_option('-f', '--fast', action='store_true',
1169 help='Do not retrieve review status')
1170 (options, args) = parser.parse_args(args)
1171 if args:
1172 parser.error('Unsupported args: %s' % args)
1173
1174 if options.field:
1175 cl = Changelist()
1176 if options.field.startswith('desc'):
1177 print cl.GetDescription()
1178 elif options.field == 'id':
1179 issueid = cl.GetIssue()
1180 if issueid:
1181 print issueid
1182 elif options.field == 'patch':
1183 patchset = cl.GetPatchset()
1184 if patchset:
1185 print patchset
1186 elif options.field == 'url':
1187 url = cl.GetIssueURL()
1188 if url:
1189 print url
1190 return 0
1191
1192 branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
1193 if not branches:
1194 print('No local branch found.')
1195 return 0
1196
1197 changes = (Changelist(branchref=b) for b in branches.splitlines())
1198 branches = dict((c.GetBranch(), c.GetIssueURL()) for c in changes)
1199 alignment = max(5, max(len(b) for b in branches))
1200 print 'Branches associated with reviews:'
1201 # Adhoc thread pool to request data concurrently.
1202 output = Queue.Queue()
1203
1204 # Silence upload.py otherwise it becomes unweldly.
1205 upload.verbosity = 0
1206
1207 if not options.fast:
1208 def fetch(b):
1209 """Fetches information for an issue and returns (branch, issue, color)."""
1210 c = Changelist(branchref=b)
1211 i = c.GetIssueURL()
1212 props = {}
1213 r = None
1214 if i:
1215 try:
1216 props = c.GetIssueProperties()
1217 r = c.GetApprovingReviewers() if i else None
1218 except urllib2.HTTPError:
1219 # The issue probably doesn't exist anymore.
1220 i += ' (broken)'
1221
1222 msgs = props.get('messages') or []
1223
1224 if not i:
1225 color = Fore.WHITE
1226 elif props.get('closed'):
1227 # Issue is closed.
1228 color = Fore.CYAN
1229 elif props.get('commit'):
1230 # Issue is in the commit queue.
1231 color = Fore.MAGENTA
1232 elif r:
1233 # Was LGTM'ed.
1234 color = Fore.GREEN
1235 elif not msgs:
1236 # No message was sent.
1237 color = Fore.RED
1238 elif msgs[-1]['sender'] != props.get('owner_email'):
1239 color = Fore.YELLOW
1240 else:
1241 color = Fore.BLUE
1242 output.put((b, i, color))
1243
1244 threads = [threading.Thread(target=fetch, args=(b,)) for b in branches]
1245 for t in threads:
1246 t.daemon = True
1247 t.start()
1248 else:
1249 # Do not use GetApprovingReviewers(), since it requires an HTTP request.
1250 for b in branches:
1251 c = Changelist(branchref=b)
1252 url = c.GetIssueURL()
1253 output.put((b, url, Fore.BLUE if url else Fore.WHITE))
1254
1255 tmp = {}
1256 alignment = max(5, max(len(ShortBranchName(b)) for b in branches))
1257 for branch in sorted(branches):
1258 while branch not in tmp:
1259 b, i, color = output.get()
1260 tmp[b] = (i, color)
1261 issue, color = tmp.pop(branch)
1262 reset = Fore.RESET
1263 if not sys.stdout.isatty():
1264 color = ''
1265 reset = ''
1266 print ' %*s : %s%s%s' % (
1267 alignment, ShortBranchName(branch), color, issue, reset)
1268
1269 cl = Changelist()
1270 print
1271 print 'Current branch:',
1272 if not cl.GetIssue():
1273 print 'no issue assigned.'
1274 return 0
1275 print cl.GetBranch()
1276 print 'Issue number: %s (%s)' % (cl.GetIssue(), cl.GetIssueURL())
1277 print 'Issue description:'
1278 print cl.GetDescription(pretty=True)
1279 return 0
1280
1281
1282 def colorize_CMDstatus_doc():
1283 """To be called once in main() to add colors to git cl status help."""
1284 colors = [i for i in dir(Fore) if i[0].isupper()]
1285
1286 def colorize_line(line):
1287 for color in colors:
1288 if color in line.upper():
1289 # Extract whitespaces first and the leading '-'.
1290 indent = len(line) - len(line.lstrip(' ')) + 1
1291 return line[:indent] + getattr(Fore, color) + line[indent:] + Fore.RESET
1292 return line
1293
1294 lines = CMDstatus.__doc__.splitlines()
1295 CMDstatus.__doc__ = '\n'.join(colorize_line(l) for l in lines)
1296
1297
1298 @subcommand.usage('[issue_number]')
1299 def CMDissue(parser, args):
1300 """Sets or displays the current code review issue number.
1301
1302 Pass issue number 0 to clear the current issue.
1303 """
1304 _, args = parser.parse_args(args)
1305
1306 cl = Changelist()
1307 if len(args) > 0:
1308 try:
1309 issue = int(args[0])
1310 except ValueError:
1311 DieWithError('Pass a number to set the issue or none to list it.\n'
1312 'Maybe you want to run git cl status?')
1313 cl.SetIssue(issue)
1314 print 'Issue number: %s (%s)' % (cl.GetIssue(), cl.GetIssueURL())
1315 return 0
1316
1317
1318 def CMDcomments(parser, args):
1319 """Shows review comments of the current changelist."""
1320 (_, args) = parser.parse_args(args)
1321 if args:
1322 parser.error('Unsupported argument: %s' % args)
1323
1324 cl = Changelist()
1325 if cl.GetIssue():
1326 data = cl.GetIssueProperties()
1327 for message in sorted(data['messages'], key=lambda x: x['date']):
1328 if message['disapproval']:
1329 color = Fore.RED
1330 elif message['approval']:
1331 color = Fore.GREEN
1332 elif message['sender'] == data['owner_email']:
1333 color = Fore.MAGENTA
1334 else:
1335 color = Fore.BLUE
1336 print '\n%s%s %s%s' % (
1337 color, message['date'].split('.', 1)[0], message['sender'],
1338 Fore.RESET)
1339 if message['text'].strip():
1340 print '\n'.join(' ' + l for l in message['text'].splitlines())
1341 return 0
1342
1343
1344 def CMDdescription(parser, args):
1345 """Brings up the editor for the current CL's description."""
1346 cl = Changelist()
1347 if not cl.GetIssue():
1348 DieWithError('This branch has no associated changelist.')
1349 description = ChangeDescription(cl.GetDescription())
1350 description.prompt()
1351 cl.UpdateDescription(description.description)
1352 return 0
1353
1354
1355 def CreateDescriptionFromLog(args):
1356 """Pulls out the commit log to use as a base for the CL description."""
1357 log_args = []
1358 if len(args) == 1 and not args[0].endswith('.'):
1359 log_args = [args[0] + '..']
1360 elif len(args) == 1 and args[0].endswith('...'):
1361 log_args = [args[0][:-1]]
1362 elif len(args) == 2:
1363 log_args = [args[0] + '..' + args[1]]
1364 else:
1365 log_args = args[:] # Hope for the best!
1366 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
1367
1368
1369 def CMDpresubmit(parser, args):
1370 """Runs presubmit tests on the current changelist."""
1371 parser.add_option('-u', '--upload', action='store_true',
1372 help='Run upload hook instead of the push/dcommit hook')
1373 parser.add_option('-f', '--force', action='store_true',
1374 help='Run checks even if tree is dirty')
1375 (options, args) = parser.parse_args(args)
1376
1377 if not options.force and is_dirty_git_tree('presubmit'):
1378 print 'use --force to check even if tree is dirty.'
1379 return 1
1380
1381 cl = Changelist()
1382 if args:
1383 base_branch = args[0]
1384 else:
1385 # Default to diffing against the common ancestor of the upstream branch.
1386 base_branch = cl.GetCommonAncestorWithUpstream()
1387
1388 cl.RunHook(
1389 committing=not options.upload,
1390 may_prompt=False,
1391 verbose=options.verbose,
1392 change=cl.GetChange(base_branch, None))
1393 return 0
1394
1395
1396 def AddChangeIdToCommitMessage(options, args):
1397 """Re-commits using the current message, assumes the commit hook is in
1398 place.
1399 """
1400 log_desc = options.message or CreateDescriptionFromLog(args)
1401 git_command = ['commit', '--amend', '-m', log_desc]
1402 RunGit(git_command)
1403 new_log_desc = CreateDescriptionFromLog(args)
1404 if CHANGE_ID in new_log_desc:
1405 print 'git-cl: Added Change-Id to commit message.'
1406 else:
1407 print >> sys.stderr, 'ERROR: Gerrit commit-msg hook not available.'
1408
1409
1410 def GerritUpload(options, args, cl):
1411 """upload the current branch to gerrit."""
1412 # We assume the remote called "origin" is the one we want.
1413 # It is probably not worthwhile to support different workflows.
1414 remote = 'origin'
1415 branch = 'master'
1416 if options.target_branch:
1417 branch = options.target_branch
1418
1419 change_desc = ChangeDescription(
1420 options.message or CreateDescriptionFromLog(args))
1421 if not change_desc.description:
1422 print "Description is empty; aborting."
1423 return 1
1424 if CHANGE_ID not in change_desc.description:
1425 AddChangeIdToCommitMessage(options, args)
1426 if options.reviewers:
1427 change_desc.update_reviewers(options.reviewers)
1428
1429 receive_options = []
1430 cc = cl.GetCCList().split(',')
1431 if options.cc:
1432 cc.extend(options.cc)
1433 cc = filter(None, cc)
1434 if cc:
1435 receive_options += ['--cc=' + email for email in cc]
1436 if change_desc.get_reviewers():
1437 receive_options.extend(
1438 '--reviewer=' + email for email in change_desc.get_reviewers())
1439
1440 git_command = ['push']
1441 if receive_options:
1442 git_command.append('--receive-pack=git receive-pack %s' %
1443 ' '.join(receive_options))
1444 git_command += [remote, 'HEAD:refs/for/' + branch]
1445 RunGit(git_command)
1446 # TODO(ukai): parse Change-Id: and set issue number?
1447 return 0
1448
1449
1450 def RietveldUpload(options, args, cl):
1451 """upload the patch to rietveld."""
1452 upload_args = ['--assume_yes'] # Don't ask about untracked files.
1453 upload_args.extend(['--server', cl.GetRietveldServer()])
1454 if options.emulate_svn_auto_props:
1455 upload_args.append('--emulate_svn_auto_props')
1456
1457 change_desc = None
1458
1459 if options.email is not None:
1460 upload_args.extend(['--email', options.email])
1461
1462 if cl.GetIssue():
1463 if options.title:
1464 upload_args.extend(['--title', options.title])
1465 if options.message:
1466 upload_args.extend(['--message', options.message])
1467 upload_args.extend(['--issue', str(cl.GetIssue())])
1468 print ("This branch is associated with issue %s. "
1469 "Adding patch to that issue." % cl.GetIssue())
1470 else:
1471 if options.title:
1472 upload_args.extend(['--title', options.title])
1473 message = options.title or options.message or CreateDescriptionFromLog(args)
1474 change_desc = ChangeDescription(message)
1475 if options.reviewers:
1476 change_desc.update_reviewers(options.reviewers)
1477 if not options.force:
1478 change_desc.prompt()
1479
1480 if not change_desc.description:
1481 print "Description is empty; aborting."
1482 return 1
1483
1484 upload_args.extend(['--message', change_desc.description])
1485 if change_desc.get_reviewers():
1486 upload_args.append('--reviewers=' + ','.join(change_desc.get_reviewers()))
1487 if options.send_mail:
1488 if not change_desc.get_reviewers():
1489 DieWithError("Must specify reviewers to send email.")
1490 upload_args.append('--send_mail')
1491
1492 # We check this before applying rietveld.private assuming that in
1493 # rietveld.cc only addresses which we can send private CLs to are listed
1494 # if rietveld.private is set, and so we should ignore rietveld.cc only when
1495 # --private is specified explicitly on the command line.
1496 if options.private:
1497 logging.warn('rietveld.cc is ignored since private flag is specified. '
1498 'You need to review and add them manually if necessary.')
1499 cc = cl.GetCCListWithoutDefault()
1500 else:
1501 cc = cl.GetCCList()
1502 cc = ','.join(filter(None, (cc, ','.join(options.cc))))
1503 if cc:
1504 upload_args.extend(['--cc', cc])
1505
1506 if options.private or settings.GetDefaultPrivateFlag() == "True":
1507 upload_args.append('--private')
1508
1509 upload_args.extend(['--git_similarity', str(options.similarity)])
1510 if not options.find_copies:
1511 upload_args.extend(['--git_no_find_copies'])
1512
1513 # Include the upstream repo's URL in the change -- this is useful for
1514 # projects that have their source spread across multiple repos.
1515 remote_url = cl.GetGitBaseUrlFromConfig()
1516 if not remote_url:
1517 if settings.GetIsGitSvn():
1518 # URL is dependent on the current directory.
1519 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
1520 if data:
1521 keys = dict(line.split(': ', 1) for line in data.splitlines()
1522 if ': ' in line)
1523 remote_url = keys.get('URL', None)
1524 else:
1525 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
1526 remote_url = (cl.GetRemoteUrl() + '@'
1527 + cl.GetUpstreamBranch().split('/')[-1])
1528 if remote_url:
1529 upload_args.extend(['--base_url', remote_url])
1530
1531 try:
1532 upload_args = ['upload'] + upload_args + args
1533 logging.info('upload.RealMain(%s)', upload_args)
1534 issue, patchset = upload.RealMain(upload_args)
1535 issue = int(issue)
1536 patchset = int(patchset)
1537 except KeyboardInterrupt:
1538 sys.exit(1)
1539 except:
1540 # If we got an exception after the user typed a description for their
1541 # change, back up the description before re-raising.
1542 if change_desc:
1543 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
1544 print '\nGot exception while uploading -- saving description to %s\n' \
1545 % backup_path
1546 backup_file = open(backup_path, 'w')
1547 backup_file.write(change_desc.description)
1548 backup_file.close()
1549 raise
1550
1551 if not cl.GetIssue():
1552 cl.SetIssue(issue)
1553 cl.SetPatchset(patchset)
1554
1555 if options.use_commit_queue:
1556 cl.SetFlag('commit', '1')
1557 return 0
1558
1559
1560 def cleanup_list(l):
1561 """Fixes a list so that comma separated items are put as individual items.
1562
1563 So that "--reviewers joe@c,john@c --reviewers joa@c" results in
1564 options.reviewers == sorted(['joe@c', 'john@c', 'joa@c']).
1565 """
1566 items = sum((i.split(',') for i in l), [])
1567 stripped_items = (i.strip() for i in items)
1568 return sorted(filter(None, stripped_items))
1569
1570
1571 @subcommand.usage('[args to "git diff"]')
1572 def CMDupload(parser, args):
1573 """Uploads the current changelist to codereview."""
1574 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
1575 help='bypass upload presubmit hook')
1576 parser.add_option('--bypass-watchlists', action='store_true',
1577 dest='bypass_watchlists',
1578 help='bypass watchlists auto CC-ing reviewers')
1579 parser.add_option('-f', action='store_true', dest='force',
1580 help="force yes to questions (don't prompt)")
1581 parser.add_option('-m', dest='message', help='message for patchset')
1582 parser.add_option('-t', dest='title', help='title for patchset')
1583 parser.add_option('-r', '--reviewers',
1584 action='append', default=[],
1585 help='reviewer email addresses')
1586 parser.add_option('--cc',
1587 action='append', default=[],
1588 help='cc email addresses')
1589 parser.add_option('-s', '--send-mail', action='store_true',
1590 help='send email to reviewer immediately')
1591 parser.add_option("--emulate_svn_auto_props", action="store_true",
1592 dest="emulate_svn_auto_props",
1593 help="Emulate Subversion's auto properties feature.")
1594 parser.add_option('-c', '--use-commit-queue', action='store_true',
1595 help='tell the commit queue to commit this patchset')
1596 parser.add_option('--private', action='store_true',
1597 help='set the review private (rietveld only)')
1598 parser.add_option('--target_branch',
1599 help='When uploading to gerrit, remote branch to '
1600 'use for CL. Default: master')
1601 parser.add_option('--email', default=None,
1602 help='email address to use to connect to Rietveld')
1603
1604 add_git_similarity(parser)
1605 (options, args) = parser.parse_args(args)
1606
1607 if options.target_branch and not settings.GetIsGerrit():
1608 parser.error('Use --target_branch for non gerrit repository.')
1609
1610 if is_dirty_git_tree('upload'):
1611 return 1
1612
1613 options.reviewers = cleanup_list(options.reviewers)
1614 options.cc = cleanup_list(options.cc)
1615
1616 cl = Changelist()
1617 if args:
1618 # TODO(ukai): is it ok for gerrit case?
1619 base_branch = args[0]
1620 else:
1621 # Default to diffing against common ancestor of upstream branch
1622 base_branch = cl.GetCommonAncestorWithUpstream()
1623 args = [base_branch, 'HEAD']
1624
1625 # Apply watchlists on upload.
1626 change = cl.GetChange(base_branch, None)
1627 watchlist = watchlists.Watchlists(change.RepositoryRoot())
1628 files = [f.LocalPath() for f in change.AffectedFiles()]
1629 if not options.bypass_watchlists:
1630 cl.SetWatchers(watchlist.GetWatchersForPaths(files))
1631
1632 if not options.bypass_hooks:
1633 hook_results = cl.RunHook(committing=False,
1634 may_prompt=not options.force,
1635 verbose=options.verbose,
1636 change=change)
1637 if not hook_results.should_continue():
1638 return 1
1639 if not options.reviewers and hook_results.reviewers:
1640 options.reviewers = hook_results.reviewers.split(',')
1641
1642 if cl.GetIssue():
1643 latest_patchset = cl.GetMostRecentPatchset()
1644 local_patchset = cl.GetPatchset()
1645 if latest_patchset and local_patchset and local_patchset != latest_patchset:
1646 print ('The last upload made from this repository was patchset #%d but '
1647 'the most recent patchset on the server is #%d.'
1648 % (local_patchset, latest_patchset))
1649 print ('Uploading will still work, but if you\'ve uploaded to this issue '
1650 'from another machine or branch the patch you\'re uploading now '
1651 'might not include those changes.')
1652 ask_for_data('About to upload; enter to confirm.')
1653
1654 print_stats(options.similarity, options.find_copies, args)
1655 if settings.GetIsGerrit():
1656 return GerritUpload(options, args, cl)
1657 ret = RietveldUpload(options, args, cl)
1658 if not ret:
1659 git_set_branch_value('last-upload-hash',
1660 RunGit(['rev-parse', 'HEAD']).strip())
1661
1662 return ret
1663
1664
1665 def IsSubmoduleMergeCommit(ref):
1666 # When submodules are added to the repo, we expect there to be a single
1667 # non-git-svn merge commit at remote HEAD with a signature comment.
1668 pattern = '^SVN changes up to revision [0-9]*$'
1669 cmd = ['rev-list', '--merges', '--grep=%s' % pattern, '%s^!' % ref]
1670 return RunGit(cmd) != ''
1671
1672
1673 def SendUpstream(parser, args, cmd):
1674 """Common code for CmdPush and CmdDCommit
1675
1676 Squashed commit into a single.
1677 Updates changelog with metadata (e.g. pointer to review).
1678 Pushes/dcommits the code upstream.
1679 Updates review and closes.
1680 """
1681 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
1682 help='bypass upload presubmit hook')
1683 parser.add_option('-m', dest='message',
1684 help="override review description")
1685 parser.add_option('-f', action='store_true', dest='force',
1686 help="force yes to questions (don't prompt)")
1687 parser.add_option('-c', dest='contributor',
1688 help="external contributor for patch (appended to " +
1689 "description and used as author for git). Should be " +
1690 "formatted as 'First Last <email@example.com>'")
1691 add_git_similarity(parser)
1692 (options, args) = parser.parse_args(args)
1693 cl = Changelist()
1694
1695 if not args or cmd == 'push':
1696 # Default to merging against our best guess of the upstream branch.
1697 args = [cl.GetUpstreamBranch()]
1698
1699 if options.contributor:
1700 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
1701 print "Please provide contibutor as 'First Last <email@example.com>'"
1702 return 1
1703
1704 base_branch = args[0]
1705 base_has_submodules = IsSubmoduleMergeCommit(base_branch)
1706
1707 if is_dirty_git_tree(cmd):
1708 return 1
1709
1710 # This rev-list syntax means "show all commits not in my branch that
1711 # are in base_branch".
1712 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
1713 base_branch]).splitlines()
1714 if upstream_commits:
1715 print ('Base branch "%s" has %d commits '
1716 'not in this branch.' % (base_branch, len(upstream_commits)))
1717 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
1718 return 1
1719
1720 # This is the revision `svn dcommit` will commit on top of.
1721 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
1722 '--pretty=format:%H'])
1723
1724 if cmd == 'dcommit':
1725 # If the base_head is a submodule merge commit, the first parent of the
1726 # base_head should be a git-svn commit, which is what we're interested in.
1727 base_svn_head = base_branch
1728 if base_has_submodules:
1729 base_svn_head += '^1'
1730
1731 extra_commits = RunGit(['rev-list', '^' + svn_head, base_svn_head])
1732 if extra_commits:
1733 print ('This branch has %d additional commits not upstreamed yet.'
1734 % len(extra_commits.splitlines()))
1735 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
1736 'before attempting to %s.' % (base_branch, cmd))
1737 return 1
1738
1739 base_branch = RunGit(['merge-base', base_branch, 'HEAD']).strip()
1740 if not options.bypass_hooks:
1741 author = None
1742 if options.contributor:
1743 author = re.search(r'\<(.*)\>', options.contributor).group(1)
1744 hook_results = cl.RunHook(
1745 committing=True,
1746 may_prompt=not options.force,
1747 verbose=options.verbose,
1748 change=cl.GetChange(base_branch, author))
1749 if not hook_results.should_continue():
1750 return 1
1751
1752 if cmd == 'dcommit':
1753 # Check the tree status if the tree status URL is set.
1754 status = GetTreeStatus()
1755 if 'closed' == status:
1756 print('The tree is closed. Please wait for it to reopen. Use '
1757 '"git cl dcommit --bypass-hooks" to commit on a closed tree.')
1758 return 1
1759 elif 'unknown' == status:
1760 print('Unable to determine tree status. Please verify manually and '
1761 'use "git cl dcommit --bypass-hooks" to commit on a closed tree.')
1762 else:
1763 breakpad.SendStack(
1764 'GitClHooksBypassedCommit',
1765 'Issue %s/%s bypassed hook when committing (tree status was "%s")' %
1766 (cl.GetRietveldServer(), cl.GetIssue(), GetTreeStatus()),
1767 verbose=False)
1768
1769 change_desc = ChangeDescription(options.message)
1770 if not change_desc.description and cl.GetIssue():
1771 change_desc = ChangeDescription(cl.GetDescription())
1772
1773 if not change_desc.description:
1774 if not cl.GetIssue() and options.bypass_hooks:
1775 change_desc = ChangeDescription(CreateDescriptionFromLog([base_branch]))
1776 else:
1777 print 'No description set.'
1778 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
1779 return 1
1780
1781 # Keep a separate copy for the commit message, because the commit message
1782 # contains the link to the Rietveld issue, while the Rietveld message contains
1783 # the commit viewvc url.
1784 # Keep a separate copy for the commit message.
1785 if cl.GetIssue():
1786 change_desc.update_reviewers(cl.GetApprovingReviewers())
1787
1788 commit_desc = ChangeDescription(change_desc.description)
1789 if cl.GetIssue():
1790 commit_desc.append_footer('Review URL: %s' % cl.GetIssueURL())
1791 if options.contributor:
1792 commit_desc.append_footer('Patch from %s.' % options.contributor)
1793
1794 print('Description:')
1795 print(commit_desc.description)
1796
1797 branches = [base_branch, cl.GetBranchRef()]
1798 if not options.force:
1799 print_stats(options.similarity, options.find_copies, branches)
1800 ask_for_data('About to commit; enter to confirm.')
1801
1802 # We want to squash all this branch's commits into one commit with the proper
1803 # description. We do this by doing a "reset --soft" to the base branch (which
1804 # keeps the working copy the same), then dcommitting that. If origin/master
1805 # has a submodule merge commit, we'll also need to cherry-pick the squashed
1806 # commit onto a branch based on the git-svn head.
1807 MERGE_BRANCH = 'git-cl-commit'
1808 CHERRY_PICK_BRANCH = 'git-cl-cherry-pick'
1809 # Delete the branches if they exist.
1810 for branch in [MERGE_BRANCH, CHERRY_PICK_BRANCH]:
1811 showref_cmd = ['show-ref', '--quiet', '--verify', 'refs/heads/%s' % branch]
1812 result = RunGitWithCode(showref_cmd)
1813 if result[0] == 0:
1814 RunGit(['branch', '-D', branch])
1815
1816 # We might be in a directory that's present in this branch but not in the
1817 # trunk. Move up to the top of the tree so that git commands that expect a
1818 # valid CWD won't fail after we check out the merge branch.
1819 rel_base_path = settings.GetRelativeRoot()
1820 if rel_base_path:
1821 os.chdir(rel_base_path)
1822
1823 # Stuff our change into the merge branch.
1824 # We wrap in a try...finally block so if anything goes wrong,
1825 # we clean up the branches.
1826 retcode = -1
1827 try:
1828 RunGit(['checkout', '-q', '-b', MERGE_BRANCH])
1829 RunGit(['reset', '--soft', base_branch])
1830 if options.contributor:
1831 RunGit(
1832 [
1833 'commit', '--author', options.contributor,
1834 '-m', commit_desc.description,
1835 ])
1836 else:
1837 RunGit(['commit', '-m', commit_desc.description])
1838 if base_has_submodules:
1839 cherry_pick_commit = RunGit(['rev-list', 'HEAD^!']).rstrip()
1840 RunGit(['branch', CHERRY_PICK_BRANCH, svn_head])
1841 RunGit(['checkout', CHERRY_PICK_BRANCH])
1842 RunGit(['cherry-pick', cherry_pick_commit])
1843 if cmd == 'push':
1844 # push the merge branch.
1845 remote, branch = cl.FetchUpstreamTuple(cl.GetBranch())
1846 retcode, output = RunGitWithCode(
1847 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1848 logging.debug(output)
1849 else:
1850 # dcommit the merge branch.
1851 retcode, output = RunGitWithCode(['svn', 'dcommit',
1852 '-C%s' % options.similarity,
1853 '--no-rebase', '--rmdir'])
1854 finally:
1855 # And then swap back to the original branch and clean up.
1856 RunGit(['checkout', '-q', cl.GetBranch()])
1857 RunGit(['branch', '-D', MERGE_BRANCH])
1858 if base_has_submodules:
1859 RunGit(['branch', '-D', CHERRY_PICK_BRANCH])
1860
1861 if cl.GetIssue():
1862 if cmd == 'dcommit' and 'Committed r' in output:
1863 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1864 elif cmd == 'push' and retcode == 0:
1865 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1866 for l in output.splitlines(False))
1867 match = filter(None, match)
1868 if len(match) != 1:
1869 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1870 output)
1871 revision = match[0].group(2)
1872 else:
1873 return 1
1874 viewvc_url = settings.GetViewVCUrl()
1875 if viewvc_url and revision:
1876 change_desc.append_footer('Committed: ' + viewvc_url + revision)
1877 elif revision:
1878 change_desc.append_footer('Committed: ' + revision)
1879 print ('Closing issue '
1880 '(you may be prompted for your codereview password)...')
1881 cl.UpdateDescription(change_desc.description)
1882 cl.CloseIssue()
1883 props = cl.GetIssueProperties()
1884 patch_num = len(props['patchsets'])
1885 comment = "Committed patchset #%d manually as r%s" % (patch_num, revision)
1886 if options.bypass_hooks:
1887 comment += ' (tree was closed).' if GetTreeStatus() == 'closed' else '.'
1888 else:
1889 comment += ' (presubmit successful).'
1890 cl.RpcServer().add_comment(cl.GetIssue(), comment)
1891 cl.SetIssue(None)
1892
1893 if retcode == 0:
1894 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1895 if os.path.isfile(hook):
1896 RunCommand([hook, base_branch], error_ok=True)
1897
1898 return 0
1899
1900
1901 @subcommand.usage('[upstream branch to apply against]')
1902 def CMDdcommit(parser, args):
1903 """Commits the current changelist via git-svn."""
1904 if not settings.GetIsGitSvn():
1905 message = """This doesn't appear to be an SVN repository.
1906 If your project has a git mirror with an upstream SVN master, you probably need
1907 to run 'git svn init', see your project's git mirror documentation.
1908 If your project has a true writeable upstream repository, you probably want
1909 to run 'git cl push' instead.
1910 Choose wisely, if you get this wrong, your commit might appear to succeed but
1911 will instead be silently ignored."""
1912 print(message)
1913 ask_for_data('[Press enter to dcommit or ctrl-C to quit]')
1914 return SendUpstream(parser, args, 'dcommit')
1915
1916
1917 @subcommand.usage('[upstream branch to apply against]')
1918 def CMDpush(parser, args):
1919 """Commits the current changelist via git."""
1920 if settings.GetIsGitSvn():
1921 print('This appears to be an SVN repository.')
1922 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
1923 ask_for_data('[Press enter to push or ctrl-C to quit]')
1924 return SendUpstream(parser, args, 'push')
1925
1926
1927 @subcommand.usage('<patch url or issue id>')
1928 def CMDpatch(parser, args):
1929 """Patches in a code review."""
1930 parser.add_option('-b', dest='newbranch',
1931 help='create a new branch off trunk for the patch')
1932 parser.add_option('-f', '--force', action='store_true',
1933 help='with -b, clobber any existing branch')
1934 parser.add_option('-d', '--directory', action='store', metavar='DIR',
1935 help='Change to the directory DIR immediately, '
1936 'before doing anything else.')
1937 parser.add_option('--reject', action='store_true',
1938 help='failed patches spew .rej files rather than '
1939 'attempting a 3-way merge')
1940 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1941 help="don't commit after patch applies")
1942 (options, args) = parser.parse_args(args)
1943 if len(args) != 1:
1944 parser.print_help()
1945 return 1
1946 issue_arg = args[0]
1947
1948 # TODO(maruel): Use apply_issue.py
1949 # TODO(ukai): use gerrit-cherry-pick for gerrit repository?
1950
1951 if options.newbranch:
1952 if options.force:
1953 RunGit(['branch', '-D', options.newbranch],
1954 stderr=subprocess2.PIPE, error_ok=True)
1955 RunGit(['checkout', '-b', options.newbranch,
1956 Changelist().GetUpstreamBranch()])
1957
1958 return PatchIssue(issue_arg, options.reject, options.nocommit,
1959 options.directory)
1960
1961
1962 def PatchIssue(issue_arg, reject, nocommit, directory):
1963 if type(issue_arg) is int or issue_arg.isdigit():
1964 # Input is an issue id. Figure out the URL.
1965 issue = int(issue_arg)
1966 cl = Changelist(issue=issue)
1967 patchset = cl.GetMostRecentPatchset()
1968 patch_data = cl.GetPatchSetDiff(issue, patchset)
1969 else:
1970 # Assume it's a URL to the patch. Default to https.
1971 issue_url = gclient_utils.UpgradeToHttps(issue_arg)
1972 match = re.match(r'.*?/issue(\d+)_(\d+).diff', issue_url)
1973 if not match:
1974 DieWithError('Must pass an issue ID or full URL for '
1975 '\'Download raw patch set\'')
1976 issue = int(match.group(1))
1977 patchset = int(match.group(2))
1978 patch_data = urllib2.urlopen(issue_arg).read()
1979
1980 # Switch up to the top-level directory, if necessary, in preparation for
1981 # applying the patch.
1982 top = settings.GetRelativeRoot()
1983 if top:
1984 os.chdir(top)
1985
1986 # Git patches have a/ at the beginning of source paths. We strip that out
1987 # with a sed script rather than the -p flag to patch so we can feed either
1988 # Git or svn-style patches into the same apply command.
1989 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
1990 try:
1991 patch_data = subprocess2.check_output(
1992 ['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'], stdin=patch_data)
1993 except subprocess2.CalledProcessError:
1994 DieWithError('Git patch mungling failed.')
1995 logging.info(patch_data)
1996
1997 # We use "git apply" to apply the patch instead of "patch" so that we can
1998 # pick up file adds.
1999 # The --index flag means: also insert into the index (so we catch adds).
2000 cmd = ['git', 'apply', '--index', '-p0']
2001 if directory:
2002 cmd.extend(('--directory', directory))
2003 if reject:
2004 cmd.append('--reject')
2005 elif IsGitVersionAtLeast('1.7.12'):
2006 cmd.append('--3way')
2007 try:
2008 subprocess2.check_call(cmd, env=GetNoGitPagerEnv(),
2009 stdin=patch_data, stdout=subprocess2.VOID)
2010 except subprocess2.CalledProcessError:
2011 DieWithError('Failed to apply the patch')
2012
2013 # If we had an issue, commit the current state and register the issue.
2014 if not nocommit:
2015 RunGit(['commit', '-m', 'patch from issue %s' % issue])
2016 cl = Changelist()
2017 cl.SetIssue(issue)
2018 cl.SetPatchset(patchset)
2019 print "Committed patch locally."
2020 else:
2021 print "Patch applied to index."
2022 return 0
2023
2024
2025 def CMDrebase(parser, args):
2026 """Rebases current branch on top of svn repo."""
2027 # Provide a wrapper for git svn rebase to help avoid accidental
2028 # git svn dcommit.
2029 # It's the only command that doesn't use parser at all since we just defer
2030 # execution to git-svn.
2031
2032 return RunGitWithCode(['svn', 'rebase'] + args)[1]
2033
2034
2035 def GetTreeStatus(url=None):
2036 """Fetches the tree status and returns either 'open', 'closed',
2037 'unknown' or 'unset'."""
2038 url = url or settings.GetTreeStatusUrl(error_ok=True)
2039 if url:
2040 status = urllib2.urlopen(url).read().lower()
2041 if status.find('closed') != -1 or status == '0':
2042 return 'closed'
2043 elif status.find('open') != -1 or status == '1':
2044 return 'open'
2045 return 'unknown'
2046 return 'unset'
2047
2048
2049 def GetTreeStatusReason():
2050 """Fetches the tree status from a json url and returns the message
2051 with the reason for the tree to be opened or closed."""
2052 url = settings.GetTreeStatusUrl()
2053 json_url = urlparse.urljoin(url, '/current?format=json')
2054 connection = urllib2.urlopen(json_url)
2055 status = json.loads(connection.read())
2056 connection.close()
2057 return status['message']
2058
2059
2060 def CMDtree(parser, args):
2061 """Shows the status of the tree."""
2062 _, args = parser.parse_args(args)
2063 status = GetTreeStatus()
2064 if 'unset' == status:
2065 print 'You must configure your tree status URL by running "git cl config".'
2066 return 2
2067
2068 print "The tree is %s" % status
2069 print
2070 print GetTreeStatusReason()
2071 if status != 'open':
2072 return 1
2073 return 0
2074
2075
2076 def CMDtry(parser, args):
2077 """Triggers a try job through Rietveld."""
2078 group = optparse.OptionGroup(parser, "Try job options")
2079 group.add_option(
2080 "-b", "--bot", action="append",
2081 help=("IMPORTANT: specify ONE builder per --bot flag. Use it multiple "
2082 "times to specify multiple builders. ex: "
2083 "'-bwin_rel:ui_tests,webkit_unit_tests -bwin_layout'. See "
2084 "the try server waterfall for the builders name and the tests "
2085 "available. Can also be used to specify gtest_filter, e.g. "
2086 "-bwin_rel:base_unittests:ValuesTest.*Value"))
2087 group.add_option(
2088 "-m", "--master", default='',
2089 help=("Specify a try master where to run the tries."))
2090 group.add_option(
2091 "-r", "--revision",
2092 help="Revision to use for the try job; default: the "
2093 "revision will be determined by the try server; see "
2094 "its waterfall for more info")
2095 group.add_option(
2096 "-c", "--clobber", action="store_true", default=False,
2097 help="Force a clobber before building; e.g. don't do an "
2098 "incremental build")
2099 group.add_option(
2100 "--project",
2101 help="Override which project to use. Projects are defined "
2102 "server-side to define what default bot set to use")
2103 group.add_option(
2104 "-t", "--testfilter", action="append", default=[],
2105 help=("Apply a testfilter to all the selected builders. Unless the "
2106 "builders configurations are similar, use multiple "
2107 "--bot <builder>:<test> arguments."))
2108 group.add_option(
2109 "-n", "--name", help="Try job name; default to current branch name")
2110 parser.add_option_group(group)
2111 options, args = parser.parse_args(args)
2112
2113 if args:
2114 parser.error('Unknown arguments: %s' % args)
2115
2116 cl = Changelist()
2117 if not cl.GetIssue():
2118 parser.error('Need to upload first')
2119
2120 if not options.name:
2121 options.name = cl.GetBranch()
2122
2123 def GetMasterMap():
2124 # Process --bot and --testfilter.
2125 if not options.bot:
2126 change = cl.GetChange(cl.GetCommonAncestorWithUpstream(), None)
2127
2128 # Get try masters from PRESUBMIT.py files.
2129 masters = presubmit_support.DoGetTryMasters(
2130 change,
2131 change.LocalPaths(),
2132 settings.GetRoot(),
2133 None,
2134 None,
2135 options.verbose,
2136 sys.stdout)
2137 if masters:
2138 return masters
2139
2140 # Fall back to deprecated method: get try slaves from PRESUBMIT.py files.
2141 options.bot = presubmit_support.DoGetTrySlaves(
2142 change,
2143 change.LocalPaths(),
2144 settings.GetRoot(),
2145 None,
2146 None,
2147 options.verbose,
2148 sys.stdout)
2149 if not options.bot:
2150 parser.error('No default try builder to try, use --bot')
2151
2152 builders_and_tests = {}
2153 # TODO(machenbach): The old style command-line options don't support
2154 # multiple try masters yet.
2155 old_style = filter(lambda x: isinstance(x, basestring), options.bot)
2156 new_style = filter(lambda x: isinstance(x, tuple), options.bot)
2157
2158 for bot in old_style:
2159 if ':' in bot:
2160 builder, tests = bot.split(':', 1)
2161 builders_and_tests.setdefault(builder, []).extend(tests.split(','))
2162 elif ',' in bot:
2163 parser.error('Specify one bot per --bot flag')
2164 else:
2165 builders_and_tests.setdefault(bot, []).append('defaulttests')
2166
2167 for bot, tests in new_style:
2168 builders_and_tests.setdefault(bot, []).extend(tests)
2169
2170 # Return a master map with one master to be backwards compatible. The
2171 # master name defaults to an empty string, which will cause the master
2172 # not to be set on rietveld (deprecated).
2173 return {options.master: builders_and_tests}
2174
2175 masters = GetMasterMap()
2176
2177 if options.testfilter:
2178 forced_tests = sum((t.split(',') for t in options.testfilter), [])
2179 masters = dict((master, dict(
2180 (b, forced_tests) for b, t in slaves.iteritems()
2181 if t != ['compile'])) for master, slaves in masters.iteritems())
2182
2183 for builders in masters.itervalues():
2184 if any('triggered' in b for b in builders):
2185 print >> sys.stderr, (
2186 'ERROR You are trying to send a job to a triggered bot. This type of'
2187 ' bot requires an\ninitial job from a parent (usually a builder). '
2188 'Instead send your job to the parent.\n'
2189 'Bot list: %s' % builders)
2190 return 1
2191
2192 patchset = cl.GetMostRecentPatchset()
2193 if patchset and patchset != cl.GetPatchset():
2194 print(
2195 '\nWARNING Mismatch between local config and server. Did a previous '
2196 'upload fail?\ngit-cl try always uses latest patchset from rietveld. '
2197 'Continuing using\npatchset %s.\n' % patchset)
2198 try:
2199 cl.RpcServer().trigger_distributed_try_jobs(
2200 cl.GetIssue(), patchset, options.name, options.clobber,
2201 options.revision, masters)
2202 except urllib2.HTTPError, e:
2203 if e.code == 404:
2204 print('404 from rietveld; '
2205 'did you mean to use "git try" instead of "git cl try"?')
2206 return 1
2207 print('Tried jobs on:')
2208
2209 for (master, builders) in masters.iteritems():
2210 if master:
2211 print 'Master: %s' % master
2212 length = max(len(builder) for builder in builders)
2213 for builder in sorted(builders):
2214 print ' %*s: %s' % (length, builder, ','.join(builders[builder]))
2215 return 0
2216
2217
2218 @subcommand.usage('[new upstream branch]')
2219 def CMDupstream(parser, args):
2220 """Prints or sets the name of the upstream branch, if any."""
2221 _, args = parser.parse_args(args)
2222 if len(args) > 1:
2223 parser.error('Unrecognized args: %s' % ' '.join(args))
2224
2225 cl = Changelist()
2226 if args:
2227 # One arg means set upstream branch.
2228 RunGit(['branch', '--set-upstream', cl.GetBranch(), args[0]])
2229 cl = Changelist()
2230 print "Upstream branch set to " + cl.GetUpstreamBranch()
2231 else:
2232 print cl.GetUpstreamBranch()
2233 return 0
2234
2235
2236 def CMDweb(parser, args):
2237 """Opens the current CL in the web browser."""
2238 _, args = parser.parse_args(args)
2239 if args:
2240 parser.error('Unrecognized args: %s' % ' '.join(args))
2241
2242 issue_url = Changelist().GetIssueURL()
2243 if not issue_url:
2244 print >> sys.stderr, 'ERROR No issue to open'
2245 return 1
2246
2247 webbrowser.open(issue_url)
2248 return 0
2249
2250
2251 def CMDset_commit(parser, args):
2252 """Sets the commit bit to trigger the Commit Queue."""
2253 _, args = parser.parse_args(args)
2254 if args:
2255 parser.error('Unrecognized args: %s' % ' '.join(args))
2256 cl = Changelist()
2257 cl.SetFlag('commit', '1')
2258 return 0
2259
2260
2261 def CMDset_close(parser, args):
2262 """Closes the issue."""
2263 _, args = parser.parse_args(args)
2264 if args:
2265 parser.error('Unrecognized args: %s' % ' '.join(args))
2266 cl = Changelist()
2267 # Ensure there actually is an issue to close.
2268 cl.GetDescription()
2269 cl.CloseIssue()
2270 return 0
2271
2272
2273 def CMDdiff(parser, args):
2274 """shows differences between local tree and last upload."""
2275 cl = Changelist()
2276 issue = cl.GetIssue()
2277 branch = cl.GetBranch()
2278 if not issue:
2279 DieWithError('No issue found for current branch (%s)' % branch)
2280 TMP_BRANCH = 'git-cl-diff'
2281 base_branch = cl.GetCommonAncestorWithUpstream()
2282
2283 # Create a new branch based on the merge-base
2284 RunGit(['checkout', '-q', '-b', TMP_BRANCH, base_branch])
2285 try:
2286 # Patch in the latest changes from rietveld.
2287 rtn = PatchIssue(issue, False, False, None)
2288 if rtn != 0:
2289 return rtn
2290
2291 # Switch back to starting brand and diff against the temporary
2292 # branch containing the latest rietveld patch.
2293 subprocess2.check_call(['git', 'diff', TMP_BRANCH, branch])
2294 finally:
2295 RunGit(['checkout', '-q', branch])
2296 RunGit(['branch', '-D', TMP_BRANCH])
2297
2298 return 0
2299
2300
2301 def CMDowners(parser, args):
2302 """interactively find the owners for reviewing"""
2303 parser.add_option(
2304 '--no-color',
2305 action='store_true',
2306 help='Use this option to disable color output')
2307 options, args = parser.parse_args(args)
2308
2309 author = RunGit(['config', 'user.email']).strip() or None
2310
2311 cl = Changelist()
2312
2313 if args:
2314 if len(args) > 1:
2315 parser.error('Unknown args')
2316 base_branch = args[0]
2317 else:
2318 # Default to diffing against the common ancestor of the upstream branch.
2319 base_branch = cl.GetCommonAncestorWithUpstream()
2320
2321 change = cl.GetChange(base_branch, None)
2322 return owners_finder.OwnersFinder(
2323 [f.LocalPath() for f in
2324 cl.GetChange(base_branch, None).AffectedFiles()],
2325 change.RepositoryRoot(), author,
2326 fopen=file, os_path=os.path, glob=glob.glob,
2327 disable_color=options.no_color).run()
2328
2329
2330 @subcommand.usage('[files or directories to diff]')
2331 def CMDformat(parser, args):
2332 """Runs clang-format on the diff."""
2333 CLANG_EXTS = ['.cc', '.cpp', '.h', '.mm', '.proto']
2334 parser.add_option('--full', action='store_true',
2335 help='Reformat the full content of all touched files')
2336 parser.add_option('--dry-run', action='store_true',
2337 help='Don\'t modify any file on disk.')
2338 opts, args = parser.parse_args(args)
2339
2340 # git diff generates paths against the root of the repository. Change
2341 # to that directory so clang-format can find files even within subdirs.
2342 rel_base_path = settings.GetRelativeRoot()
2343 if rel_base_path:
2344 os.chdir(rel_base_path)
2345
2346 # Generate diff for the current branch's changes.
2347 diff_cmd = ['diff', '--no-ext-diff', '--no-prefix']
2348 if opts.full:
2349 # Only list the names of modified files.
2350 diff_cmd.append('--name-only')
2351 else:
2352 # Only generate context-less patches.
2353 diff_cmd.append('-U0')
2354
2355 # Grab the merge-base commit, i.e. the upstream commit of the current
2356 # branch when it was created or the last time it was rebased. This is
2357 # to cover the case where the user may have called "git fetch origin",
2358 # moving the origin branch to a newer commit, but hasn't rebased yet.
2359 upstream_commit = None
2360 cl = Changelist()
2361 upstream_branch = cl.GetUpstreamBranch()
2362 if upstream_branch:
2363 upstream_commit = RunGit(['merge-base', 'HEAD', upstream_branch])
2364 upstream_commit = upstream_commit.strip()
2365
2366 if not upstream_commit:
2367 DieWithError('Could not find base commit for this branch. '
2368 'Are you in detached state?')
2369
2370 diff_cmd.append(upstream_commit)
2371
2372 # Handle source file filtering.
2373 diff_cmd.append('--')
2374 if args:
2375 for arg in args:
2376 if os.path.isdir(arg):
2377 diff_cmd += [os.path.join(arg, '*' + ext) for ext in CLANG_EXTS]
2378 elif os.path.isfile(arg):
2379 diff_cmd.append(arg)
2380 else:
2381 DieWithError('Argument "%s" is not a file or a directory' % arg)
2382 else:
2383 diff_cmd += ['*' + ext for ext in CLANG_EXTS]
2384 diff_output = RunGit(diff_cmd)
2385
2386 top_dir = os.path.normpath(
2387 RunGit(["rev-parse", "--show-toplevel"]).rstrip('\n'))
2388
2389 # Locate the clang-format binary in the checkout
2390 try:
2391 clang_format_tool = clang_format.FindClangFormatToolInChromiumTree()
2392 except clang_format.NotFoundError, e:
2393 DieWithError(e)
2394
2395 if opts.full:
2396 # diff_output is a list of files to send to clang-format.
2397 files = diff_output.splitlines()
2398 if not files:
2399 print "Nothing to format."
2400 return 0
2401 cmd = [clang_format_tool]
2402 if not opts.dry_run:
2403 cmd.append('-i')
2404 stdout = RunCommand(cmd + files, cwd=top_dir)
2405 else:
2406 env = os.environ.copy()
2407 env['PATH'] = os.path.dirname(clang_format_tool)
2408 # diff_output is a patch to send to clang-format-diff.py
2409 try:
2410 script = clang_format.FindClangFormatScriptInChromiumTree(
2411 'clang-format-diff.py')
2412 except clang_format.NotFoundError, e:
2413 DieWithError(e)
2414
2415 cmd = [sys.executable, script, '-p0']
2416 if not opts.dry_run:
2417 cmd.append('-i')
2418
2419 stdout = RunCommand(cmd, stdin=diff_output, cwd=top_dir, env=env)
2420 if opts.dry_run and len(stdout) > 0:
2421 return 2
2422
2423 return 0
2424
2425
2426 class OptionParser(optparse.OptionParser):
2427 """Creates the option parse and add --verbose support."""
2428 def __init__(self, *args, **kwargs):
2429 optparse.OptionParser.__init__(
2430 self, *args, prog='git cl', version=__version__, **kwargs)
2431 self.add_option(
2432 '-v', '--verbose', action='count', default=0,
2433 help='Use 2 times for more debugging info')
2434
2435 def parse_args(self, args=None, values=None):
2436 options, args = optparse.OptionParser.parse_args(self, args, values)
2437 levels = [logging.WARNING, logging.INFO, logging.DEBUG]
2438 logging.basicConfig(level=levels[min(options.verbose, len(levels) - 1)])
2439 return options, args
2440
2441
2442 def main(argv):
2443 if sys.hexversion < 0x02060000:
2444 print >> sys.stderr, (
2445 '\nYour python version %s is unsupported, please upgrade.\n' %
2446 sys.version.split(' ', 1)[0])
2447 return 2
2448
2449 # Reload settings.
2450 global settings
2451 settings = Settings()
2452
2453 colorize_CMDstatus_doc()
2454 dispatcher = subcommand.CommandDispatcher(__name__)
2455 try:
2456 return dispatcher.execute(OptionParser(), argv)
2457 except urllib2.HTTPError, e:
2458 if e.code != 500:
2459 raise
2460 DieWithError(
2461 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
2462 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
2463
2464
2465 if __name__ == '__main__':
2466 # These affect sys.stdout so do it outside of main() to simplify mocks in
2467 # unit testing.
2468 fix_encoding.fix_encoding()
2469 colorama.init()
2470 sys.exit(main(sys.argv[1:]))
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698