git-llvm 9 KB
#!/usr/bin/env python
#
# ======- git-llvm - LLVM Git Help Integration ---------*- python -*--========#
#
# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
# See https://llvm.org/LICENSE.txt for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
#
# ==------------------------------------------------------------------------==#

"""
git-llvm integration
====================

This file provides integration for git.

The git llvm push sub-command can be used to push changes to GitHub.  It is
designed to be a thin wrapper around git, and its main purpose is to
detect and prevent merge commits from being pushed to the main repository.

Usage:

git-llvm push <upstream-branch>

This will push changes from the current HEAD to the branch <upstream-branch>.

"""

from __future__ import print_function
import argparse
import collections
import os
import re
import shutil
import subprocess
import sys
import time
import getpass
assert sys.version_info >= (2, 7)

try:
    dict.iteritems
except AttributeError:
    # Python 3
    def iteritems(d):
        return iter(d.items())
else:
    # Python 2
    def iteritems(d):
        return d.iteritems()

try:
    # Python 3
    from shlex import quote
except ImportError:
    # Python 2
    from pipes import quote

# It's *almost* a straightforward mapping from the monorepo to svn...
LLVM_MONOREPO_SVN_MAPPING = {
    d: (d + '/trunk')
    for d in [
        'clang-tools-extra',
        'compiler-rt',
        'debuginfo-tests',
        'dragonegg',
        'klee',
        'libc',
        'libclc',
        'libcxx',
        'libcxxabi',
        'libunwind',
        'lld',
        'lldb',
        'llgo',
        'llvm',
        'openmp',
        'parallel-libs',
        'polly',
        'pstl',
    ]
}
LLVM_MONOREPO_SVN_MAPPING.update({'clang': 'cfe/trunk'})
LLVM_MONOREPO_SVN_MAPPING.update({'': 'monorepo-root/trunk'})

SPLIT_REPO_NAMES = {'llvm-' + d: d + '/trunk'
                    for d in ['www', 'zorg', 'test-suite', 'lnt']}

VERBOSE = False
QUIET = False
dev_null_fd = None

GIT_ORG = 'llvm'
GIT_REPO = 'llvm-project'
GIT_URL = 'github.com/{}/{}.git'.format(GIT_ORG, GIT_REPO)


def eprint(*args, **kwargs):
    print(*args, file=sys.stderr, **kwargs)


def log(*args, **kwargs):
    if QUIET:
        return
    print(*args, **kwargs)


def log_verbose(*args, **kwargs):
    if not VERBOSE:
        return
    print(*args, **kwargs)


def die(msg):
    eprint(msg)
    sys.exit(1)


def ask_confirm(prompt):
    # Python 2/3 compatibility
    try:
        read_input = raw_input
    except NameError:
        read_input = input

    while True:
        query = read_input('%s (y/N): ' % (prompt))
        if query.lower() not in ['y','n', '']:
           print('Expect y or n!')
           continue
        return query.lower() == 'y'


def get_dev_null():
    """Lazily create a /dev/null fd for use in shell()"""
    global dev_null_fd
    if dev_null_fd is None:
        dev_null_fd = open(os.devnull, 'w')
    return dev_null_fd


def shell(cmd, strip=True, cwd=None, stdin=None, die_on_failure=True,
          ignore_errors=False, text=True, print_raw_stderr=False):
    # Escape args when logging for easy repro.
    quoted_cmd = [quote(arg) for arg in cmd]
    log_verbose('Running in %s: %s' % (cwd, ' '.join(quoted_cmd)))

    err_pipe = subprocess.PIPE
    if ignore_errors:
        # Silence errors if requested.
        err_pipe = get_dev_null()

    start = time.time()
    p = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=err_pipe,
                         stdin=subprocess.PIPE,
                         universal_newlines=text)
    stdout, stderr = p.communicate(input=stdin)
    elapsed = time.time() - start

    log_verbose('Command took %0.1fs' % elapsed)

    if p.returncode == 0 or ignore_errors:
        if stderr and not ignore_errors:
            if not print_raw_stderr:
                eprint('`%s` printed to stderr:' % ' '.join(quoted_cmd))
            eprint(stderr.rstrip())
        if strip:
            if text:
                stdout = stdout.rstrip('\r\n')
            else:
                stdout = stdout.rstrip(b'\r\n')
        if VERBOSE:
            for l in stdout.splitlines():
                log_verbose("STDOUT: %s" % l)
        return stdout
    err_msg = '`%s` returned %s' % (' '.join(quoted_cmd), p.returncode)
    eprint(err_msg)
    if stderr:
        eprint(stderr.rstrip())
    if die_on_failure:
        sys.exit(2)
    raise RuntimeError(err_msg)


def git(*cmd, **kwargs):
    return shell(['git'] + list(cmd), **kwargs)


def svn(cwd, *cmd, **kwargs):
    return shell(['svn'] + list(cmd), cwd=cwd, **kwargs)


def program_exists(cmd):
    if sys.platform == 'win32' and not cmd.endswith('.exe'):
        cmd += '.exe'
    for path in os.environ["PATH"].split(os.pathsep):
        if os.access(os.path.join(path, cmd), os.X_OK):
            return True
    return False


def get_fetch_url():
    return 'https://{}'.format(GIT_URL)


def get_push_url(user='', ssh=False):

    if ssh:
        return 'ssh://git@{}'.format(GIT_URL)

    return 'https://{}'.format(GIT_URL)


def get_revs_to_push(branch):
    # Fetch the latest upstream to determine which commits will be pushed.
    git('fetch', get_fetch_url(), branch)

    commits = git('rev-list', '--ancestry-path', 'FETCH_HEAD..HEAD').splitlines()
    # Reverse the order so we commit the oldest commit first
    commits.reverse()
    return commits


def git_push_one_rev(rev, dry_run, branch, ssh):
    # Check if this a merge commit by counting the number of parent commits.
    # More than 1 parent commmit means this is a merge.
    num_parents = len(git('show', '--no-patch', '--format="%P"', rev).split())

    if num_parents > 1:
        raise Exception("Merge commit detected, cannot push ", rev)

    if num_parents != 1:
        raise Exception("Error detecting number of parents for ", rev)

    if dry_run:
        print("[DryRun] Would push", rev)
        return

    # Second push to actually push the commit
    git('push', get_push_url(ssh=ssh), '{}:{}'.format(rev, branch), print_raw_stderr=True)


def cmd_push(args):
    '''Push changes to git:'''
    dry_run = args.dry_run

    revs = get_revs_to_push(args.branch)

    if not revs:
        die('Nothing to push')

    log('%sPushing %d commit%s:\n%s' %
        ('[DryRun] ' if dry_run else '', len(revs),
         's' if len(revs) != 1 else '',
         '\n'.join('  ' + git('show', '--oneline', '--quiet', c)
                   for c in revs)))

    # Ask confirmation if multiple commits are about to be pushed
    if not args.force and len(revs) > 1:
        if not ask_confirm("Are you sure you want to create %d commits?" % len(revs)):
            die("Aborting")

    for r in revs:
        git_push_one_rev(r, dry_run, args.branch, args.ssh)


if __name__ == '__main__':
    if not program_exists('git'):
        die('error: git-llvm needs git command, but git is not installed.')

    argv = sys.argv[1:]
    p = argparse.ArgumentParser(
        prog='git llvm', formatter_class=argparse.RawDescriptionHelpFormatter,
        description=__doc__)
    subcommands = p.add_subparsers(title='subcommands',
                                   description='valid subcommands',
                                   help='additional help')
    verbosity_group = p.add_mutually_exclusive_group()
    verbosity_group.add_argument('-q', '--quiet', action='store_true',
                                 help='print less information')
    verbosity_group.add_argument('-v', '--verbose', action='store_true',
                                 help='print more information')

    parser_push = subcommands.add_parser(
        'push', description=cmd_push.__doc__,
        help='push changes back to the LLVM SVN repository')
    parser_push.add_argument(
        '-n',
        '--dry-run',
        dest='dry_run',
        action='store_true',
        help='Do everything other than commit to svn.  Leaves junk in the svn '
        'repo, so probably will not work well if you try to commit more '
        'than one rev.')
    parser_push.add_argument(
        '-s',
        '--ssh',
        dest='ssh',
        action='store_true',
        help='Use the SSH protocol for authentication, '
        'instead of HTTPS with username and password.')
    parser_push.add_argument(
        '-f',
        '--force',
        action='store_true',
        help='Do not ask for confirmation when pushing multiple commits.')
    parser_push.add_argument(
        'branch',
        metavar='GIT_BRANCH',
        type=str,
        default='master',
        nargs='?',
        help="branch to push (default: everything not in the branch's "
        'upstream)')
    parser_push.set_defaults(func=cmd_push)

    args = p.parse_args(argv)
    VERBOSE = args.verbose
    QUIET = args.quiet

    # Python3 workaround, for when not arguments are provided.
    # See https://bugs.python.org/issue16308
    try:
        func = args.func
    except AttributeError:
        # No arguments or subcommands were given.
        parser.print_help()
        parser.exit()

    # Dispatch to the right subcommand
    args.func(args)