blob: d93f58533fe29498ac5f5ebe7f40d4b9d65ca035 [file] [log] [blame]
#!/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.
"""
from __future__ import print_function
import argparse
import collections
import contextlib
import errno
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
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()
# It's *almost* a straightforward mapping from the monorepo to svn...
GIT_TO_SVN_DIR = {
d: (d + '/trunk')
for d in [
'clang-tools-extra',
'compiler-rt',
'debuginfo-tests',
'dragonegg',
'klee',
'libclc',
'libcxx',
'libcxxabi',
'libunwind',
'lld',
'lldb',
'llgo',
'llvm',
'openmp',
'parallel-libs',
'polly',
'pstl',
]
}
GIT_TO_SVN_DIR.update({'clang': 'cfe/trunk'})
GIT_TO_SVN_DIR.update({'': 'monorepo-root/trunk'})
VERBOSE = False
QUIET = False
dev_null_fd = None
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 split_first_path_component(d):
# Assuming we have a git path, it'll use slashes even on windows...I hope.
if '/' in d:
return d.split('/', 1)
else:
return (d, None)
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):
log_verbose('Running in %s: %s' % (cwd, ' '.join(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:
eprint('`%s` printed to stderr:' % ' '.join(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(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_default_rev_range():
# Get the branch tracked by the current branch, as set by
# git branch --set-upstream-to See http://serverfault.com/a/352236/38694.
cur_branch = git('rev-parse', '--symbolic-full-name', 'HEAD')
upstream_branch = git('for-each-ref', '--format=%(upstream:short)',
cur_branch)
if not upstream_branch:
upstream_branch = 'origin/master'
# Get the newest common ancestor between HEAD and our upstream branch.
upstream_rev = git('merge-base', 'HEAD', upstream_branch)
return '%s..' % upstream_rev
def get_revs_to_push(rev_range):
if not rev_range:
rev_range = get_default_rev_range()
# Use git show rather than some plumbing command to figure out which revs
# are in rev_range because it handles single revs (HEAD^) and ranges
# (foo..bar) like we want.
revs = git('show', '--reverse', '--quiet',
'--pretty=%h', rev_range).splitlines()
if not revs:
die('Nothing to push: No revs in range %s.' % rev_range)
return revs
def clean_svn(svn_repo):
svn(svn_repo, 'revert', '-R', '.')
# Unfortunately it appears there's no svn equivalent for git clean, so we
# have to do it ourselves.
for line in svn(svn_repo, 'status', '--no-ignore').split('\n'):
if not line.startswith('?'):
continue
filename = line[1:].strip()
filepath = os.path.abspath(os.path.join(svn_repo, filename))
abs_svn_repo = os.path.abspath(svn_repo)
# Safety check that the directory we are about to delete is
# actually within our svn staging dir.
if not filepath.startswith(abs_svn_repo):
die("Path to clean (%s) is not in svn staging dir (%s)"
% (filepath, abs_svn_repo))
if os.path.isdir(filepath):
shutil.rmtree(filepath)
else:
os.remove(filepath)
def svn_init(svn_root):
if not os.path.exists(svn_root):
log('Creating svn staging directory: (%s)' % (svn_root))
os.makedirs(svn_root)
svn(svn_root, 'checkout', '--depth=empty',
'https://llvm.org/svn/llvm-project/', '.')
log("svn staging area ready in '%s'" % svn_root)
if not os.path.isdir(svn_root):
die("Can't initialize svn staging dir (%s)" % svn_root)
def fix_eol_style_native(rev, svn_sr_path, files):
"""Fix line endings before applying patches with Unix endings
SVN on Windows will check out files with CRLF for files with the
svn:eol-style property set to "native". This breaks `git apply`, which
typically works with Unix-line ending patches. Work around the problem here
by doing a dos2unix up front for files with svn:eol-style set to "native".
SVN will not commit a mass line ending re-doing because it detects the line
ending format for files with this property.
"""
# Skip files that don't exist in SVN yet.
files = [f for f in files if os.path.exists(os.path.join(svn_sr_path, f))]
# Use ignore_errors because 'svn propget' prints errors if the file doesn't
# have the named property. There doesn't seem to be a way to suppress that.
eol_props = svn(svn_sr_path, 'propget', 'svn:eol-style', *files,
ignore_errors=True)
crlf_files = []
if len(files) == 1:
# No need to split propget output on ' - ' when we have one file.
if eol_props.strip() in ['native', 'CRLF']:
crlf_files = files
else:
for eol_prop in eol_props.split('\n'):
# Remove spare CR.
eol_prop = eol_prop.strip('\r')
if not eol_prop:
continue
prop_parts = eol_prop.rsplit(' - ', 1)
if len(prop_parts) != 2:
eprint("unable to parse svn propget line:")
eprint(eol_prop)
continue
(f, eol_style) = prop_parts
if eol_style == 'native':
crlf_files.append(f)
if crlf_files:
# Reformat all files with native SVN line endings to Unix format. SVN
# knows files with native line endings are text files. It will commit
# just the diff, and not a mass line ending change.
shell(['dos2unix'] + crlf_files, ignore_errors=True, cwd=svn_sr_path)
def split_subrepo(f):
# Given a path, splits it into (subproject, rest-of-path). If the path is
# not in a subproject, returns ('', full-path).
subproject, remainder = split_first_path_component(f)
if subproject in GIT_TO_SVN_DIR:
return subproject, remainder
else:
return '', f
def get_all_parent_dirs(name):
parts = []
head, tail = os.path.split(name)
while head:
parts.append(head)
head, tail = os.path.split(head)
return parts
def svn_push_one_rev(svn_repo, rev, dry_run):
files = git('diff-tree', '--no-commit-id', '--name-only', '-r',
rev).split('\n')
if not files:
raise RuntimeError('Empty diff for rev %s?' % rev)
# Split files by subrepo
subrepo_files = collections.defaultdict(list)
for f in files:
subrepo, remainder = split_subrepo(f)
subrepo_files[subrepo].append(remainder)
status = svn(svn_repo, 'status', '--no-ignore')
if status:
die("Can't push git rev %s because svn status is not empty:\n%s" %
(rev, status))
svn_dirs_to_update = set()
for sr, files in iteritems(subrepo_files):
svn_sr_path = GIT_TO_SVN_DIR[sr]
for f in files:
svn_dirs_to_update.add(
os.path.dirname(os.path.join(svn_sr_path, f)))
# We also need to svn update any parent directories which are not yet present
parent_dirs = set()
for dir in svn_dirs_to_update:
parent_dirs.update(get_all_parent_dirs(dir))
parent_dirs = set(dir for dir in parent_dirs
if not os.path.exists(os.path.join(svn_repo, dir)))
svn_dirs_to_update.update(parent_dirs)
# Sort by length to ensure that the parent directories are passed to svn
# before child directories.
sorted_dirs_to_update = sorted(svn_dirs_to_update, key=len)
# SVN update only in the affected directories.
svn(svn_repo, 'update', '--depth=files', *sorted_dirs_to_update)
for sr, files in iteritems(subrepo_files):
svn_sr_path = os.path.join(svn_repo, GIT_TO_SVN_DIR[sr])
if os.name == 'nt':
fix_eol_style_native(rev, svn_sr_path, files)
# We use text=False (and pass '--binary') so that we can get an exact
# diff that can be passed as-is to 'git apply' without any line ending,
# encoding, or other mangling.
diff = git('show', '--binary', rev, '--',
*(os.path.join(sr, f) for f in files),
strip=False, text=False)
# git is the only thing that can handle its own patches...
if sr == '':
prefix_strip = '-p1'
else:
prefix_strip = '-p2'
try:
shell(['git', 'apply', prefix_strip, '-'], cwd=svn_sr_path,
stdin=diff, die_on_failure=False, text=False)
except RuntimeError as e:
eprint("Patch doesn't apply: maybe you should try `git pull -r` "
"first?")
sys.exit(2)
status_lines = svn(svn_repo, 'status', '--no-ignore').split('\n')
for l in (l for l in status_lines if (l.startswith('?') or
l.startswith('I'))):
svn(svn_repo, 'add', '--no-ignore', l[1:].strip())
for l in (l for l in status_lines if l.startswith('!')):
svn(svn_repo, 'remove', l[1:].strip())
# Now we're ready to commit.
commit_msg = git('show', '--pretty=%B', '--quiet', rev)
if not dry_run:
commit_args = ['commit', '-m', commit_msg]
if '--force-interactive' in svn(svn_repo, 'commit', '--help'):
commit_args.append('--force-interactive')
log(svn(svn_repo, *commit_args))
log('Committed %s to svn.' % rev)
else:
log("Would have committed %s to svn, if this weren't a dry run." % rev)
def cmd_push(args):
'''Push changes back to SVN: this is extracted from Justin Lebar's script
available here: https://github.com/jlebar/llvm-repo-tools/
Note: a current limitation is that git does not track file rename, so they
will show up in SVN as delete+add.
'''
# Get the git root
git_root = git('rev-parse', '--show-toplevel')
if not os.path.isdir(git_root):
die("Can't find git root dir")
# Push from the root of the git repo
os.chdir(git_root)
# We need a staging area for SVN, let's hide it in the .git directory.
dot_git_dir = git('rev-parse', '--git-common-dir')
svn_root = os.path.join(dot_git_dir, 'llvm-upstream-svn')
svn_init(svn_root)
rev_range = args.rev_range
dry_run = args.dry_run
revs = get_revs_to_push(rev_range)
log('Pushing %d commit%s:\n%s' %
(len(revs), 's' if len(revs) != 1
else '', '\n'.join(' ' + git('show', '--oneline', '--quiet', c)
for c in revs)))
for r in revs:
clean_svn(svn_root)
svn_push_one_rev(svn_root, r, dry_run)
if __name__ == '__main__':
if not program_exists('svn'):
die('error: git-llvm needs svn command, but svn 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(
'rev_range',
metavar='GIT_REVS',
type=str,
nargs='?',
help="revs to push (default: everything not in the branch's "
'upstream, or not in origin/master if the branch lacks '
'an explicit upstream)')
parser_push.set_defaults(func=cmd_push)
args = p.parse_args(argv)
VERBOSE = args.verbose
QUIET = args.quiet
# Dispatch to the right subcommand
args.func(args)