| Index: appengine/third_party_local/depot_tools/git_common.py
|
| diff --git a/appengine/third_party_local/depot_tools/git_common.py b/appengine/third_party_local/depot_tools/git_common.py
|
| index 1215d9cd41d44385f98583c884188b006b8561f1..a18f59f16474d19e5ed52085dedc70af42f2be8e 100644
|
| --- a/appengine/third_party_local/depot_tools/git_common.py
|
| +++ b/appengine/third_party_local/depot_tools/git_common.py
|
| @@ -1,4 +1,4 @@
|
| -# Copyright 2013 The Chromium Authors. All rights reserved.
|
| +# Copyright 2014 The Chromium Authors. All rights reserved.
|
| # Use of this source code is governed by a BSD-style license that can be
|
| # found in the LICENSE file.
|
|
|
| @@ -16,19 +16,85 @@ IMapIterator.__next__ = IMapIterator.next
|
|
|
|
|
| import binascii
|
| +import collections
|
| import contextlib
|
| import functools
|
| import logging
|
| +import os
|
| +import re
|
| import signal
|
| import sys
|
| import tempfile
|
| +import textwrap
|
| import threading
|
|
|
| import subprocess2
|
|
|
| +ROOT = os.path.abspath(os.path.dirname(__file__))
|
|
|
| -GIT_EXE = 'git.bat' if sys.platform.startswith('win') else 'git'
|
| +GIT_EXE = ROOT+'\\git.bat' if sys.platform.startswith('win') else 'git'
|
| +TEST_MODE = False
|
|
|
| +FREEZE = 'FREEZE'
|
| +FREEZE_SECTIONS = {
|
| + 'indexed': 'soft',
|
| + 'unindexed': 'mixed'
|
| +}
|
| +FREEZE_MATCHER = re.compile(r'%s.(%s)' % (FREEZE, '|'.join(FREEZE_SECTIONS)))
|
| +
|
| +
|
| +# Retry a git operation if git returns a error response with any of these
|
| +# messages. It's all observed 'bad' GoB responses so far.
|
| +#
|
| +# This list is inspired/derived from the one in ChromiumOS's Chromite:
|
| +# <CHROMITE>/lib/git.py::GIT_TRANSIENT_ERRORS
|
| +#
|
| +# It was last imported from '7add3ac29564d98ac35ce426bc295e743e7c0c02'.
|
| +GIT_TRANSIENT_ERRORS = (
|
| + # crbug.com/285832
|
| + r'!.*\[remote rejected\].*\(error in hook\)',
|
| +
|
| + # crbug.com/289932
|
| + r'!.*\[remote rejected\].*\(failed to lock\)',
|
| +
|
| + # crbug.com/307156
|
| + r'!.*\[remote rejected\].*\(error in Gerrit backend\)',
|
| +
|
| + # crbug.com/285832
|
| + r'remote error: Internal Server Error',
|
| +
|
| + # crbug.com/294449
|
| + r'fatal: Couldn\'t find remote ref ',
|
| +
|
| + # crbug.com/220543
|
| + r'git fetch_pack: expected ACK/NAK, got',
|
| +
|
| + # crbug.com/189455
|
| + r'protocol error: bad pack header',
|
| +
|
| + # crbug.com/202807
|
| + r'The remote end hung up unexpectedly',
|
| +
|
| + # crbug.com/298189
|
| + r'TLS packet with unexpected length was received',
|
| +
|
| + # crbug.com/187444
|
| + r'RPC failed; result=\d+, HTTP code = \d+',
|
| +
|
| + # crbug.com/388876
|
| + r'Connection timed out',
|
| +
|
| + # crbug.com/430343
|
| + # TODO(dnj): Resync with Chromite.
|
| + r'The requested URL returned error: 5\d+',
|
| +)
|
| +
|
| +GIT_TRANSIENT_ERRORS_RE = re.compile('|'.join(GIT_TRANSIENT_ERRORS),
|
| + re.IGNORECASE)
|
| +
|
| +# First version where the for-each-ref command's format string supported the
|
| +# upstream:track token.
|
| +MIN_UPSTREAM_TRACK_GIT_VERSION = (1, 9)
|
|
|
| class BadCommitRefException(Exception):
|
| def __init__(self, refs):
|
| @@ -139,7 +205,7 @@ def ScopedPool(*args, **kwargs):
|
|
|
| class ProgressPrinter(object):
|
| """Threaded single-stat status message printer."""
|
| - def __init__(self, fmt, enabled=None, stream=sys.stderr, period=0.5):
|
| + def __init__(self, fmt, enabled=None, fout=sys.stderr, period=0.5):
|
| """Create a ProgressPrinter.
|
|
|
| Use it as a context manager which produces a simple 'increment' method:
|
| @@ -155,7 +221,7 @@ class ProgressPrinter(object):
|
| should go.
|
| enabled (bool) - If this is None, will default to True if
|
| logging.getLogger() is set to INFO or more verbose.
|
| - stream (file-like) - The stream to print status messages to.
|
| + fout (file-like) - The stream to print status messages to.
|
| period (float) - The time in seconds for the printer thread to wait
|
| between printing.
|
| """
|
| @@ -168,7 +234,7 @@ class ProgressPrinter(object):
|
| self._count = 0
|
| self._dead = False
|
| self._dead_cond = threading.Condition()
|
| - self._stream = stream
|
| + self._stream = fout
|
| self._thread = threading.Thread(target=self._run)
|
| self._period = period
|
|
|
| @@ -199,6 +265,225 @@ class ProgressPrinter(object):
|
| del self._thread
|
|
|
|
|
| +def once(function):
|
| + """@Decorates |function| so that it only performs its action once, no matter
|
| + how many times the decorated |function| is called."""
|
| + def _inner_gen():
|
| + yield function()
|
| + while True:
|
| + yield
|
| + return _inner_gen().next
|
| +
|
| +
|
| +## Git functions
|
| +
|
| +
|
| +def branch_config(branch, option, default=None):
|
| + return config('branch.%s.%s' % (branch, option), default=default)
|
| +
|
| +
|
| +def branch_config_map(option):
|
| + """Return {branch: <|option| value>} for all branches."""
|
| + try:
|
| + reg = re.compile(r'^branch\.(.*)\.%s$' % option)
|
| + lines = run('config', '--get-regexp', reg.pattern).splitlines()
|
| + return {reg.match(k).group(1): v for k, v in (l.split() for l in lines)}
|
| + except subprocess2.CalledProcessError:
|
| + return {}
|
| +
|
| +
|
| +def branches(*args):
|
| + NO_BRANCH = ('* (no branch', '* (detached from ')
|
| +
|
| + key = 'depot-tools.branch-limit'
|
| + limit = 20
|
| + try:
|
| + limit = int(config(key, limit))
|
| + except ValueError:
|
| + pass
|
| +
|
| + raw_branches = run('branch', *args).splitlines()
|
| +
|
| + num = len(raw_branches)
|
| + if num > limit:
|
| + print >> sys.stderr, textwrap.dedent("""\
|
| + Your git repo has too many branches (%d/%d) for this tool to work well.
|
| +
|
| + You may adjust this limit by running:
|
| + git config %s <new_limit>
|
| + """ % (num, limit, key))
|
| + sys.exit(1)
|
| +
|
| + for line in raw_branches:
|
| + if line.startswith(NO_BRANCH):
|
| + continue
|
| + yield line.split()[-1]
|
| +
|
| +
|
| +def config(option, default=None):
|
| + try:
|
| + return run('config', '--get', option) or default
|
| + except subprocess2.CalledProcessError:
|
| + return default
|
| +
|
| +
|
| +def config_list(option):
|
| + try:
|
| + return run('config', '--get-all', option).split()
|
| + except subprocess2.CalledProcessError:
|
| + return []
|
| +
|
| +
|
| +def current_branch():
|
| + try:
|
| + return run('rev-parse', '--abbrev-ref', 'HEAD')
|
| + except subprocess2.CalledProcessError:
|
| + return None
|
| +
|
| +
|
| +def del_branch_config(branch, option, scope='local'):
|
| + del_config('branch.%s.%s' % (branch, option), scope=scope)
|
| +
|
| +
|
| +def del_config(option, scope='local'):
|
| + try:
|
| + run('config', '--' + scope, '--unset', option)
|
| + except subprocess2.CalledProcessError:
|
| + pass
|
| +
|
| +
|
| +def freeze():
|
| + took_action = False
|
| +
|
| + try:
|
| + run('commit', '-m', FREEZE + '.indexed')
|
| + took_action = True
|
| + except subprocess2.CalledProcessError:
|
| + pass
|
| +
|
| + try:
|
| + run('add', '-A')
|
| + run('commit', '-m', FREEZE + '.unindexed')
|
| + took_action = True
|
| + except subprocess2.CalledProcessError:
|
| + pass
|
| +
|
| + if not took_action:
|
| + return 'Nothing to freeze.'
|
| +
|
| +
|
| +def get_branch_tree():
|
| + """Get the dictionary of {branch: parent}, compatible with topo_iter.
|
| +
|
| + Returns a tuple of (skipped, <branch_tree dict>) where skipped is a set of
|
| + branches without upstream branches defined.
|
| + """
|
| + skipped = set()
|
| + branch_tree = {}
|
| +
|
| + for branch in branches():
|
| + parent = upstream(branch)
|
| + if not parent:
|
| + skipped.add(branch)
|
| + continue
|
| + branch_tree[branch] = parent
|
| +
|
| + return skipped, branch_tree
|
| +
|
| +
|
| +def get_or_create_merge_base(branch, parent=None):
|
| + """Finds the configured merge base for branch.
|
| +
|
| + If parent is supplied, it's used instead of calling upstream(branch).
|
| + """
|
| + base = branch_config(branch, 'base')
|
| + base_upstream = branch_config(branch, 'base-upstream')
|
| + parent = parent or upstream(branch)
|
| + if parent is None or branch is None:
|
| + return None
|
| + actual_merge_base = run('merge-base', parent, branch)
|
| +
|
| + if base_upstream != parent:
|
| + base = None
|
| + base_upstream = None
|
| +
|
| + def is_ancestor(a, b):
|
| + return run_with_retcode('merge-base', '--is-ancestor', a, b) == 0
|
| +
|
| + if base:
|
| + if not is_ancestor(base, branch):
|
| + logging.debug('Found WRONG pre-set merge-base for %s: %s', branch, base)
|
| + base = None
|
| + elif is_ancestor(base, actual_merge_base):
|
| + logging.debug('Found OLD pre-set merge-base for %s: %s', branch, base)
|
| + base = None
|
| + else:
|
| + logging.debug('Found pre-set merge-base for %s: %s', branch, base)
|
| +
|
| + if not base:
|
| + base = actual_merge_base
|
| + manual_merge_base(branch, base, parent)
|
| +
|
| + return base
|
| +
|
| +
|
| +def hash_multi(*reflike):
|
| + return run('rev-parse', *reflike).splitlines()
|
| +
|
| +
|
| +def hash_one(reflike, short=False):
|
| + args = ['rev-parse', reflike]
|
| + if short:
|
| + args.insert(1, '--short')
|
| + return run(*args)
|
| +
|
| +
|
| +def in_rebase():
|
| + git_dir = run('rev-parse', '--git-dir')
|
| + return (
|
| + os.path.exists(os.path.join(git_dir, 'rebase-merge')) or
|
| + os.path.exists(os.path.join(git_dir, 'rebase-apply')))
|
| +
|
| +
|
| +def intern_f(f, kind='blob'):
|
| + """Interns a file object into the git object store.
|
| +
|
| + Args:
|
| + f (file-like object) - The file-like object to intern
|
| + kind (git object type) - One of 'blob', 'commit', 'tree', 'tag'.
|
| +
|
| + Returns the git hash of the interned object (hex encoded).
|
| + """
|
| + ret = run('hash-object', '-t', kind, '-w', '--stdin', stdin=f)
|
| + f.close()
|
| + return ret
|
| +
|
| +
|
| +def is_dormant(branch):
|
| + # TODO(iannucci): Do an oldness check?
|
| + return branch_config(branch, 'dormant', 'false') != 'false'
|
| +
|
| +
|
| +def manual_merge_base(branch, base, parent):
|
| + set_branch_config(branch, 'base', base)
|
| + set_branch_config(branch, 'base-upstream', parent)
|
| +
|
| +
|
| +def mktree(treedict):
|
| + """Makes a git tree object and returns its hash.
|
| +
|
| + See |tree()| for the values of mode, type, and ref.
|
| +
|
| + Args:
|
| + treedict - { name: (mode, type, ref) }
|
| + """
|
| + with tempfile.TemporaryFile() as f:
|
| + for name, (mode, typ, ref) in treedict.iteritems():
|
| + f.write('%s %s %s\t%s\0' % (mode, typ, ref, name))
|
| + f.seek(0)
|
| + return run('mktree', '-z', stdin=f)
|
| +
|
| +
|
| def parse_commitrefs(*commitrefs):
|
| """Returns binary encoded commit hashes for one or more commitrefs.
|
|
|
| @@ -208,45 +493,192 @@ def parse_commitrefs(*commitrefs):
|
| * 'cool_branch~2'
|
| """
|
| try:
|
| - return map(binascii.unhexlify, hashes(*commitrefs))
|
| + return map(binascii.unhexlify, hash_multi(*commitrefs))
|
| except subprocess2.CalledProcessError:
|
| raise BadCommitRefException(commitrefs)
|
|
|
|
|
| +RebaseRet = collections.namedtuple('RebaseRet', 'success stdout stderr')
|
| +
|
| +
|
| +def rebase(parent, start, branch, abort=False):
|
| + """Rebases |start|..|branch| onto the branch |parent|.
|
| +
|
| + Args:
|
| + parent - The new parent ref for the rebased commits.
|
| + start - The commit to start from
|
| + branch - The branch to rebase
|
| + abort - If True, will call git-rebase --abort in the event that the rebase
|
| + doesn't complete successfully.
|
| +
|
| + Returns a namedtuple with fields:
|
| + success - a boolean indicating that the rebase command completed
|
| + successfully.
|
| + message - if the rebase failed, this contains the stdout of the failed
|
| + rebase.
|
| + """
|
| + try:
|
| + args = ['--onto', parent, start, branch]
|
| + if TEST_MODE:
|
| + args.insert(0, '--committer-date-is-author-date')
|
| + run('rebase', *args)
|
| + return RebaseRet(True, '', '')
|
| + except subprocess2.CalledProcessError as cpe:
|
| + if abort:
|
| + run('rebase', '--abort')
|
| + return RebaseRet(False, cpe.stdout, cpe.stderr)
|
| +
|
| +
|
| +def remove_merge_base(branch):
|
| + del_branch_config(branch, 'base')
|
| + del_branch_config(branch, 'base-upstream')
|
| +
|
| +
|
| +def root():
|
| + return config('depot-tools.upstream', 'origin/master')
|
| +
|
| +
|
| def run(*cmd, **kwargs):
|
| - """Runs a git command. Returns stdout as a string.
|
| + """The same as run_with_stderr, except it only returns stdout."""
|
| + return run_with_stderr(*cmd, **kwargs)[0]
|
| +
|
| +
|
| +def run_with_retcode(*cmd, **kwargs):
|
| + """Run a command but only return the status code."""
|
| + try:
|
| + run(*cmd, **kwargs)
|
| + return 0
|
| + except subprocess2.CalledProcessError as cpe:
|
| + return cpe.returncode
|
|
|
| - If logging is DEBUG, we'll print the command before we run it.
|
| +
|
| +def run_stream(*cmd, **kwargs):
|
| + """Runs a git command. Returns stdout as a PIPE (file-like object).
|
| +
|
| + stderr is dropped to avoid races if the process outputs to both stdout and
|
| + stderr.
|
| + """
|
| + kwargs.setdefault('stderr', subprocess2.VOID)
|
| + kwargs.setdefault('stdout', subprocess2.PIPE)
|
| + cmd = (GIT_EXE, '-c', 'color.ui=never') + cmd
|
| + proc = subprocess2.Popen(cmd, **kwargs)
|
| + return proc.stdout
|
| +
|
| +
|
| +def run_with_stderr(*cmd, **kwargs):
|
| + """Runs a git command.
|
| +
|
| + Returns (stdout, stderr) as a pair of strings.
|
|
|
| kwargs
|
| autostrip (bool) - Strip the output. Defaults to True.
|
| - Output string is always strip()'d.
|
| + indata (str) - Specifies stdin data for the process.
|
| """
|
| + kwargs.setdefault('stdin', subprocess2.PIPE)
|
| + kwargs.setdefault('stdout', subprocess2.PIPE)
|
| + kwargs.setdefault('stderr', subprocess2.PIPE)
|
| autostrip = kwargs.pop('autostrip', True)
|
| - cmd = (GIT_EXE,) + cmd
|
| - logging.debug('Running %s', ' '.join(repr(tok) for tok in cmd))
|
| - ret = subprocess2.check_output(cmd, stderr=subprocess2.PIPE, **kwargs)
|
| + indata = kwargs.pop('indata', None)
|
| +
|
| + cmd = (GIT_EXE, '-c', 'color.ui=never') + cmd
|
| + proc = subprocess2.Popen(cmd, **kwargs)
|
| + ret, err = proc.communicate(indata)
|
| + retcode = proc.wait()
|
| + if retcode != 0:
|
| + raise subprocess2.CalledProcessError(retcode, cmd, os.getcwd(), ret, err)
|
| +
|
| if autostrip:
|
| ret = (ret or '').strip()
|
| - return ret
|
| + err = (err or '').strip()
|
|
|
| + return ret, err
|
|
|
| -def hashes(*reflike):
|
| - return run('rev-parse', *reflike).splitlines()
|
|
|
| +def set_branch_config(branch, option, value, scope='local'):
|
| + set_config('branch.%s.%s' % (branch, option), value, scope=scope)
|
|
|
| -def intern_f(f, kind='blob'):
|
| - """Interns a file object into the git object store.
|
|
|
| - Args:
|
| - f (file-like object) - The file-like object to intern
|
| - kind (git object type) - One of 'blob', 'commit', 'tree', 'tag'.
|
| +def set_config(option, value, scope='local'):
|
| + run('config', '--' + scope, option, value)
|
|
|
| - Returns the git hash of the interned object (hex encoded).
|
| +
|
| +def squash_current_branch(header=None, merge_base=None):
|
| + header = header or 'git squash commit.'
|
| + merge_base = merge_base or get_or_create_merge_base(current_branch())
|
| + log_msg = header + '\n'
|
| + if log_msg:
|
| + log_msg += '\n'
|
| + log_msg += run('log', '--reverse', '--format=%H%n%B', '%s..HEAD' % merge_base)
|
| + run('reset', '--soft', merge_base)
|
| + run('commit', '-a', '-F', '-', indata=log_msg)
|
| +
|
| +
|
| +def tags(*args):
|
| + return run('tag', *args).splitlines()
|
| +
|
| +
|
| +def thaw():
|
| + took_action = False
|
| + for sha in (s.strip() for s in run_stream('rev-list', 'HEAD').xreadlines()):
|
| + msg = run('show', '--format=%f%b', '-s', 'HEAD')
|
| + match = FREEZE_MATCHER.match(msg)
|
| + if not match:
|
| + if not took_action:
|
| + return 'Nothing to thaw.'
|
| + break
|
| +
|
| + run('reset', '--' + FREEZE_SECTIONS[match.group(1)], sha)
|
| + took_action = True
|
| +
|
| +
|
| +def topo_iter(branch_tree, top_down=True):
|
| + """Generates (branch, parent) in topographical order for a branch tree.
|
| +
|
| + Given a tree:
|
| +
|
| + A1
|
| + B1 B2
|
| + C1 C2 C3
|
| + D1
|
| +
|
| + branch_tree would look like: {
|
| + 'D1': 'C3',
|
| + 'C3': 'B2',
|
| + 'B2': 'A1',
|
| + 'C1': 'B1',
|
| + 'C2': 'B1',
|
| + 'B1': 'A1',
|
| + }
|
| +
|
| + It is OK to have multiple 'root' nodes in your graph.
|
| +
|
| + if top_down is True, items are yielded from A->D. Otherwise they're yielded
|
| + from D->A. Within a layer the branches will be yielded in sorted order.
|
| """
|
| - ret = run('hash-object', '-t', kind, '-w', '--stdin', stdin=f)
|
| - f.close()
|
| - return ret
|
| + branch_tree = branch_tree.copy()
|
| +
|
| + # TODO(iannucci): There is probably a more efficient way to do these.
|
| + if top_down:
|
| + while branch_tree:
|
| + this_pass = [(b, p) for b, p in branch_tree.iteritems()
|
| + if p not in branch_tree]
|
| + assert this_pass, "Branch tree has cycles: %r" % branch_tree
|
| + for branch, parent in sorted(this_pass):
|
| + yield branch, parent
|
| + del branch_tree[branch]
|
| + else:
|
| + parent_to_branches = collections.defaultdict(set)
|
| + for branch, parent in branch_tree.iteritems():
|
| + parent_to_branches[parent].add(branch)
|
| +
|
| + while branch_tree:
|
| + this_pass = [(b, p) for b, p in branch_tree.iteritems()
|
| + if not parent_to_branches[b]]
|
| + assert this_pass, "Branch tree has cycles: %r" % branch_tree
|
| + for branch, parent in sorted(this_pass):
|
| + yield branch, parent
|
| + parent_to_branches[parent].discard(branch)
|
| + del branch_tree[branch]
|
|
|
|
|
| def tree(treeref, recurse=False):
|
| @@ -286,16 +718,54 @@ def tree(treeref, recurse=False):
|
| return ret
|
|
|
|
|
| -def mktree(treedict):
|
| - """Makes a git tree object and returns its hash.
|
| +def upstream(branch):
|
| + try:
|
| + return run('rev-parse', '--abbrev-ref', '--symbolic-full-name',
|
| + branch+'@{upstream}')
|
| + except subprocess2.CalledProcessError:
|
| + return None
|
|
|
| - See |tree()| for the values of mode, type, and ref.
|
|
|
| - Args:
|
| - treedict - { name: (mode, type, ref) }
|
| - """
|
| - with tempfile.TemporaryFile() as f:
|
| - for name, (mode, typ, ref) in treedict.iteritems():
|
| - f.write('%s %s %s\t%s\0' % (mode, typ, ref, name))
|
| - f.seek(0)
|
| - return run('mktree', '-z', stdin=f)
|
| +def get_git_version():
|
| + """Returns a tuple that contains the numeric components of the current git
|
| + version."""
|
| + version_string = run('--version')
|
| + version_match = re.search(r'(\d+.)+(\d+)', version_string)
|
| + version = version_match.group() if version_match else ''
|
| +
|
| + return tuple(int(x) for x in version.split('.'))
|
| +
|
| +
|
| +def get_branches_info(include_tracking_status):
|
| + format_string = (
|
| + '--format=%(refname:short):%(objectname:short):%(upstream:short):')
|
| +
|
| + # This is not covered by the depot_tools CQ which only has git version 1.8.
|
| + if (include_tracking_status and
|
| + get_git_version() >= MIN_UPSTREAM_TRACK_GIT_VERSION): # pragma: no cover
|
| + format_string += '%(upstream:track)'
|
| +
|
| + info_map = {}
|
| + data = run('for-each-ref', format_string, 'refs/heads')
|
| + BranchesInfo = collections.namedtuple(
|
| + 'BranchesInfo', 'hash upstream ahead behind')
|
| + for line in data.splitlines():
|
| + (branch, branch_hash, upstream_branch, tracking_status) = line.split(':')
|
| +
|
| + ahead_match = re.search(r'ahead (\d+)', tracking_status)
|
| + ahead = int(ahead_match.group(1)) if ahead_match else None
|
| +
|
| + behind_match = re.search(r'behind (\d+)', tracking_status)
|
| + behind = int(behind_match.group(1)) if behind_match else None
|
| +
|
| + info_map[branch] = BranchesInfo(
|
| + hash=branch_hash, upstream=upstream_branch, ahead=ahead, behind=behind)
|
| +
|
| + # Set None for upstreams which are not branches (e.g empty upstream, remotes
|
| + # and deleted upstream branches).
|
| + missing_upstreams = {}
|
| + for info in info_map.values():
|
| + if info.upstream not in info_map and info.upstream not in missing_upstreams:
|
| + missing_upstreams[info.upstream] = None
|
| +
|
| + return dict(info_map.items() + missing_upstreams.items())
|
|
|