Home | History | Annotate | Download | only in git-svn
      1 #!/usr/bin/env python
      2 #
      3 # ======- git-llvm - LLVM Git Help Integration ---------*- python -*--========#
      4 #
      5 #                     The LLVM Compiler Infrastructure
      6 #
      7 # This file is distributed under the University of Illinois Open Source
      8 # License. See LICENSE.TXT for details.
      9 #
     10 # ==------------------------------------------------------------------------==#
     11 
     12 """
     13 git-llvm integration
     14 ====================
     15 
     16 This file provides integration for git.
     17 """
     18 
     19 from __future__ import print_function
     20 import argparse
     21 import collections
     22 import contextlib
     23 import errno
     24 import os
     25 import re
     26 import subprocess
     27 import sys
     28 import tempfile
     29 import time
     30 assert sys.version_info >= (2, 7)
     31 
     32 
     33 # It's *almost* a straightforward mapping from the monorepo to svn...
     34 GIT_TO_SVN_DIR = {
     35     d: (d + '/trunk')
     36     for d in [
     37         'clang-tools-extra',
     38         'compiler-rt',
     39         'debuginfo-tests',
     40         'dragonegg',
     41         'klee',
     42         'libclc',
     43         'libcxx',
     44         'libcxxabi',
     45         'libunwind',
     46         'lld',
     47         'lldb',
     48         'llgo',
     49         'llvm',
     50         'openmp',
     51         'parallel-libs',
     52         'polly',
     53     ]
     54 }
     55 GIT_TO_SVN_DIR.update({'clang': 'cfe/trunk'})
     56 
     57 VERBOSE = False
     58 QUIET = False
     59 dev_null_fd = None
     60 
     61 
     62 def eprint(*args, **kwargs):
     63     print(*args, file=sys.stderr, **kwargs)
     64 
     65 
     66 def log(*args, **kwargs):
     67     if QUIET:
     68         return
     69     print(*args, **kwargs)
     70 
     71 
     72 def log_verbose(*args, **kwargs):
     73     if not VERBOSE:
     74         return
     75     print(*args, **kwargs)
     76 
     77 
     78 def die(msg):
     79     eprint(msg)
     80     sys.exit(1)
     81 
     82 
     83 def first_dirname(d):
     84     while True:
     85         (head, tail) = os.path.split(d)
     86         if not head or head == '/':
     87             return tail
     88         d = head
     89 
     90 
     91 def get_dev_null():
     92     """Lazily create a /dev/null fd for use in shell()"""
     93     global dev_null_fd
     94     if dev_null_fd is None:
     95         dev_null_fd = open(os.devnull, 'w')
     96     return dev_null_fd
     97 
     98 
     99 def shell(cmd, strip=True, cwd=None, stdin=None, die_on_failure=True,
    100           ignore_errors=False):
    101     log_verbose('Running: %s' % ' '.join(cmd))
    102 
    103     err_pipe = subprocess.PIPE
    104     if ignore_errors:
    105         # Silence errors if requested.
    106         err_pipe = get_dev_null()
    107 
    108     start = time.time()
    109     p = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=err_pipe,
    110                          stdin=subprocess.PIPE, universal_newlines=True)
    111     stdout, stderr = p.communicate(input=stdin)
    112     elapsed = time.time() - start
    113 
    114     log_verbose('Command took %0.1fs' % elapsed)
    115 
    116     if p.returncode == 0 or ignore_errors:
    117         if stderr and not ignore_errors:
    118             eprint('`%s` printed to stderr:' % ' '.join(cmd))
    119             eprint(stderr.rstrip())
    120         if strip:
    121             stdout = stdout.rstrip('\r\n')
    122         return stdout
    123     err_msg = '`%s` returned %s' % (' '.join(cmd), p.returncode)
    124     eprint(err_msg)
    125     if stderr:
    126         eprint(stderr.rstrip())
    127     if die_on_failure:
    128         sys.exit(2)
    129     raise RuntimeError(err_msg)
    130 
    131 
    132 def git(*cmd, **kwargs):
    133     return shell(['git'] + list(cmd), kwargs.get('strip', True))
    134 
    135 
    136 def svn(cwd, *cmd, **kwargs):
    137     # TODO: Better way to do default arg when we have *cmd?
    138     return shell(['svn'] + list(cmd), cwd=cwd, stdin=kwargs.get('stdin', None),
    139                  ignore_errors=kwargs.get('ignore_errors', None))
    140 
    141 def program_exists(cmd):
    142     if sys.platform == 'win32' and not cmd.endswith('.exe'):
    143         cmd += '.exe'
    144     for path in os.environ["PATH"].split(os.pathsep):
    145         if os.access(os.path.join(path, cmd), os.X_OK):
    146             return True
    147     return False
    148 
    149 def get_default_rev_range():
    150     # Get the branch tracked by the current branch, as set by
    151     # git branch --set-upstream-to  See http://serverfault.com/a/352236/38694.
    152     cur_branch = git('rev-parse', '--symbolic-full-name', 'HEAD')
    153     upstream_branch = git('for-each-ref', '--format=%(upstream:short)',
    154                           cur_branch)
    155     if not upstream_branch:
    156         upstream_branch = 'origin/master'
    157 
    158     # Get the newest common ancestor between HEAD and our upstream branch.
    159     upstream_rev = git('merge-base', 'HEAD', upstream_branch)
    160     return '%s..' % upstream_rev
    161 
    162 
    163 def get_revs_to_push(rev_range):
    164     if not rev_range:
    165         rev_range = get_default_rev_range()
    166     # Use git show rather than some plumbing command to figure out which revs
    167     # are in rev_range because it handles single revs (HEAD^) and ranges
    168     # (foo..bar) like we want.
    169     revs = git('show', '--reverse', '--quiet',
    170                '--pretty=%h', rev_range).splitlines()
    171     if not revs:
    172         die('Nothing to push: No revs in range %s.' % rev_range)
    173     return revs
    174 
    175 
    176 def clean_and_update_svn(svn_repo):
    177     svn(svn_repo, 'revert', '-R', '.')
    178 
    179     # Unfortunately it appears there's no svn equivalent for git clean, so we
    180     # have to do it ourselves.
    181     for line in svn(svn_repo, 'status', '--no-ignore').split('\n'):
    182         if not line.startswith('?'):
    183             continue
    184         filename = line[1:].strip()
    185         os.remove(os.path.join(svn_repo, filename))
    186 
    187     svn(svn_repo, 'update', *list(GIT_TO_SVN_DIR.values()))
    188 
    189 
    190 def svn_init(svn_root):
    191     if not os.path.exists(svn_root):
    192         log('Creating svn staging directory: (%s)' % (svn_root))
    193         os.makedirs(svn_root)
    194         log('This is a one-time initialization, please be patient for a few'
    195             ' minutes...')
    196         svn(svn_root, 'checkout', '--depth=immediates',
    197             'https://llvm.org/svn/llvm-project/', '.')
    198         svn(svn_root, 'update', *list(GIT_TO_SVN_DIR.values()))
    199         log("svn staging area ready in '%s'" % svn_root)
    200     if not os.path.isdir(svn_root):
    201         die("Can't initialize svn staging dir (%s)" % svn_root)
    202 
    203 
    204 def fix_eol_style_native(rev, sr, svn_sr_path):
    205     """Fix line endings before applying patches with Unix endings
    206 
    207     SVN on Windows will check out files with CRLF for files with the
    208     svn:eol-style property set to "native". This breaks `git apply`, which
    209     typically works with Unix-line ending patches. Work around the problem here
    210     by doing a dos2unix up front for files with svn:eol-style set to "native".
    211     SVN will not commit a mass line ending re-doing because it detects the line
    212     ending format for files with this property.
    213     """
    214     files = git('diff-tree', '--no-commit-id', '--name-only', '-r', rev, '--',
    215                 sr).split('\n')
    216     files = [f.split('/', 1)[1] for f in files]
    217     # Skip files that don't exist in SVN yet.
    218     files = [f for f in files if os.path.exists(os.path.join(svn_sr_path, f))]
    219     # Use ignore_errors because 'svn propget' prints errors if the file doesn't
    220     # have the named property. There doesn't seem to be a way to suppress that.
    221     eol_props = svn(svn_sr_path, 'propget', 'svn:eol-style', *files,
    222                     ignore_errors=True)
    223     crlf_files = []
    224     if len(files) == 1:
    225         # No need to split propget output on ' - ' when we have one file.
    226         if eol_props.strip() == 'native':
    227             crlf_files = files
    228     else:
    229         for eol_prop in eol_props.split('\n'):
    230             # Remove spare CR.
    231             eol_prop = eol_prop.strip('\r')
    232             if not eol_prop:
    233                 continue
    234             prop_parts = eol_prop.rsplit(' - ', 1)
    235             if len(prop_parts) != 2:
    236                 eprint("unable to parse svn propget line:")
    237                 eprint(eol_prop)
    238                 continue
    239             (f, eol_style) = prop_parts
    240             if eol_style == 'native':
    241                 crlf_files.append(f)
    242     # Reformat all files with native SVN line endings to Unix format. SVN knows
    243     # files with native line endings are text files. It will commit just the
    244     # diff, and not a mass line ending change.
    245     shell(['dos2unix', '-q'] + crlf_files, cwd=svn_sr_path)
    246 
    247 
    248 def svn_push_one_rev(svn_repo, rev, dry_run):
    249     files = git('diff-tree', '--no-commit-id', '--name-only', '-r',
    250                 rev).split('\n')
    251     subrepos = {first_dirname(f) for f in files}
    252     if not subrepos:
    253         raise RuntimeError('Empty diff for rev %s?' % rev)
    254 
    255     status = svn(svn_repo, 'status', '--no-ignore')
    256     if status:
    257         die("Can't push git rev %s because svn status is not empty:\n%s" %
    258             (rev, status))
    259 
    260     for sr in subrepos:
    261         svn_sr_path = os.path.join(svn_repo, GIT_TO_SVN_DIR[sr])
    262         if os.name == 'nt':
    263             fix_eol_style_native(rev, sr, svn_sr_path)
    264         diff = git('show', '--binary', rev, '--', sr, strip=False)
    265         # git is the only thing that can handle its own patches...
    266         log_verbose('Apply patch: %s' % diff)
    267         try:
    268             shell(['git', 'apply', '-p2', '-'], cwd=svn_sr_path, stdin=diff,
    269                   die_on_failure=False)
    270         except RuntimeError as e:
    271             eprint("Patch doesn't apply: maybe you should try `git pull -r` "
    272                    "first?")
    273             sys.exit(2)
    274 
    275     status_lines = svn(svn_repo, 'status', '--no-ignore').split('\n')
    276 
    277     for l in (l for l in status_lines if (l.startswith('?') or
    278                                           l.startswith('I'))):
    279         svn(svn_repo, 'add', '--no-ignore', l[1:].strip())
    280     for l in (l for l in status_lines if l.startswith('!')):
    281         svn(svn_repo, 'remove', l[1:].strip())
    282 
    283     # Now we're ready to commit.
    284     commit_msg = git('show', '--pretty=%B', '--quiet', rev)
    285     if not dry_run:
    286         log(svn(svn_repo, 'commit', '-m', commit_msg, '--force-interactive'))
    287         log('Committed %s to svn.' % rev)
    288     else:
    289         log("Would have committed %s to svn, if this weren't a dry run." % rev)
    290 
    291 
    292 def cmd_push(args):
    293     '''Push changes back to SVN: this is extracted from Justin Lebar's script
    294     available here: https://github.com/jlebar/llvm-repo-tools/
    295 
    296     Note: a current limitation is that git does not track file rename, so they
    297     will show up in SVN as delete+add.
    298     '''
    299     # Get the git root
    300     git_root = git('rev-parse', '--show-toplevel')
    301     if not os.path.isdir(git_root):
    302         die("Can't find git root dir")
    303 
    304     # Push from the root of the git repo
    305     os.chdir(git_root)
    306 
    307     # We need a staging area for SVN, let's hide it in the .git directory.
    308     dot_git_dir = git('rev-parse', '--git-common-dir')
    309     svn_root = os.path.join(dot_git_dir, 'llvm-upstream-svn')
    310     svn_init(svn_root)
    311 
    312     rev_range = args.rev_range
    313     dry_run = args.dry_run
    314     revs = get_revs_to_push(rev_range)
    315     log('Pushing %d commit%s:\n%s' %
    316         (len(revs), 's' if len(revs) != 1
    317          else '', '\n'.join('  ' + git('show', '--oneline', '--quiet', c)
    318                             for c in revs)))
    319     for r in revs:
    320         clean_and_update_svn(svn_root)
    321         svn_push_one_rev(svn_root, r, dry_run)
    322 
    323 
    324 if __name__ == '__main__':
    325     if not program_exists('svn'):
    326         die('error: git-llvm needs svn command, but svn is not installed.')
    327 
    328     argv = sys.argv[1:]
    329     p = argparse.ArgumentParser(
    330         prog='git llvm', formatter_class=argparse.RawDescriptionHelpFormatter,
    331         description=__doc__)
    332     subcommands = p.add_subparsers(title='subcommands',
    333                                    description='valid subcommands',
    334                                    help='additional help')
    335     verbosity_group = p.add_mutually_exclusive_group()
    336     verbosity_group.add_argument('-q', '--quiet', action='store_true',
    337                                  help='print less information')
    338     verbosity_group.add_argument('-v', '--verbose', action='store_true',
    339                                  help='print more information')
    340 
    341     parser_push = subcommands.add_parser(
    342         'push', description=cmd_push.__doc__,
    343         help='push changes back to the LLVM SVN repository')
    344     parser_push.add_argument(
    345         '-n',
    346         '--dry-run',
    347         dest='dry_run',
    348         action='store_true',
    349         help='Do everything other than commit to svn.  Leaves junk in the svn '
    350         'repo, so probably will not work well if you try to commit more '
    351         'than one rev.')
    352     parser_push.add_argument(
    353         'rev_range',
    354         metavar='GIT_REVS',
    355         type=str,
    356         nargs='?',
    357         help="revs to push (default: everything not in the branch's "
    358         'upstream, or not in origin/master if the branch lacks '
    359         'an explicit upstream)')
    360     parser_push.set_defaults(func=cmd_push)
    361     args = p.parse_args(argv)
    362     VERBOSE = args.verbose
    363     QUIET = args.quiet
    364 
    365     # Dispatch to the right subcommand
    366     args.func(args)
    367