#!/usr/bin/env python3
# Copyright lowRISC contributors (OpenTitan project).
# Licensed under the Apache License, Version 2.0, see LICENSE for details.
# SPDX-License-Identifier: Apache-2.0

'''A tool to copy source code from upstream into this repository.

For an introduction to using this tool, see doc/ug/vendor_hw.md in this
repository (on the internet at https://docs.opentitan.org/doc/ug/vendor_hw/).

For full documentation, see doc/rm/vendor_in_tool.md (on the internet at
https://docs.opentitan.org/doc/rm/vendor_in_tool).

'''

import argparse
import fnmatch
import logging as log
import os
import re
import shutil
import subprocess
import sys
import tempfile
import textwrap
from pathlib import Path

import hjson

EXCLUDE_ALWAYS = ['.git']

LOCK_FILE_HEADER = """// Copyright lowRISC contributors (OpenTitan project).
// Licensed under the Apache License, Version 2.0, see LICENSE for details.
// SPDX-License-Identifier: Apache-2.0

// This file is generated by the util/vendor script. Please do not modify it
// manually.

"""

# Keys in the description (configuration) file which can be overridden through
# the command line.
OVERRIDABLE_DESC_KEYS = [
    'patch_repo.url',
    'patch_repo.rev_base',
    'patch_repo.rev_patched',
    'upstream.url',
    'upstream.rev',
]

verbose = False


def git_is_clean_workdir(git_workdir):
    """Check if the git working directory is clean (no unstaged or staged changes)"""
    cmd = ['git', 'status', '--untracked-files=no', '--porcelain']
    modified_files = subprocess.run(cmd,
                                    cwd=str(git_workdir),
                                    check=True,
                                    stdout=subprocess.PIPE,
                                    stderr=subprocess.PIPE).stdout.strip()
    return not modified_files


def github_qualify_references(log, repo_userorg, repo_name):
    """ Replace "unqualified" GitHub references with "fully qualified" one

    GitHub automatically links issues and pull requests if they have a specific
    format. Links can be qualified with the user/org name and the repository
    name, or unqualified, if they only contain the issue or pull request number.

    This function converts all unqualified references to qualified ones.

    See https://help.github.com/en/articles/autolinked-references-and-urls#issues-and-pull-requests
    for a documentation of all supported formats.
    """

    r = re.compile(r"(^|[^\w])(?:#|[gG][hH]-)(\d+)\b")
    repl_str = r'\1%s/%s#\2' % (repo_userorg, repo_name)
    return [r.sub(repl_str, line) for line in log]


def test_github_qualify_references():
    repo_userorg = 'lowRISC'
    repo_name = 'ibex'

    # Unqualified references, should be replaced
    items_unqualified = [
        '#28',
        'GH-27',
        'klaus #27',
        'Fixes #27',
        'Fixes #27 and #28',
        '(#27)',
        'something (#27) done',
        '#27 and (GH-38)',
    ]
    exp_items_unqualified = [
        'lowRISC/ibex#28',
        'lowRISC/ibex#27',
        'klaus lowRISC/ibex#27',
        'Fixes lowRISC/ibex#27',
        'Fixes lowRISC/ibex#27 and lowRISC/ibex#28',
        '(lowRISC/ibex#27)',
        'something (lowRISC/ibex#27) done',
        'lowRISC/ibex#27 and (lowRISC/ibex#38)',
    ]
    assert github_qualify_references(items_unqualified, repo_userorg,
                                     repo_name) == exp_items_unqualified

    # Qualified references, should stay as they are
    items_qualified = [
        'Fixes lowrisc/ibex#27',
        'lowrisc/ibex#2',
    ]
    assert github_qualify_references(items_qualified, repo_userorg,
                                     repo_name) == items_qualified

    # Invalid references, should stay as they are
    items_invalid = [
        'something#27',
        'lowrisc/ibex#',
    ]
    assert github_qualify_references(items_invalid, repo_userorg,
                                     repo_name) == items_invalid


def test_github_parse_url():
    assert github_parse_url('https://example.com/something/asdf.git') is None
    assert github_parse_url('https://github.com/lowRISC/ibex.git') == (
        'lowRISC', 'ibex')
    assert github_parse_url('https://github.com/lowRISC/ibex') == ('lowRISC',
                                                                   'ibex')
    assert github_parse_url('git@github.com:lowRISC/ibex.git') == ('lowRISC',
                                                                   'ibex')


def github_parse_url(github_repo_url):
    """Parse a GitHub repository URL into its parts.

    Return a tuple (userorg, name), or None if the parsing failed.
    """

    regex = r"(?:@github\.com\:|\/github\.com\/)([a-zA-Z\d-]+)\/([a-zA-Z\d-]+)(?:\.git)?$"
    m = re.search(regex, github_repo_url)
    if m is None:
        return None
    return (m.group(1), m.group(2))


def produce_shortlog(clone_dir, mapping, old_rev, new_rev):
    """ Produce a list of changes between two revisions, one revision per line

    Merges are excluded"""

    # If mapping is None, we want to list all changes below clone_dir.
    # Otherwise, we want to list changes in each 'source' in the mapping. Since
    # these strings are paths relative to clone_dir, we can just pass them all
    # to git and let it figure out what to do.
    subdirs = (['.'] if mapping is None
               else [m.from_path for m in mapping.items])

    cmd = (['git', '-C', str(clone_dir), 'log',
           '--pretty=format:%s (%aN)', '--no-merges',
            old_rev + '..' + new_rev] +
           subdirs)
    try:
        proc = subprocess.run(cmd,
                              cwd=str(clone_dir),
                              check=True,
                              stdout=subprocess.PIPE,
                              stderr=subprocess.PIPE,
                              universal_newlines=True)
        return proc.stdout.splitlines()
    except subprocess.CalledProcessError as e:
        log.error("Unable to capture shortlog: %s", e.stderr)
        return ""


def format_list_to_str(list, width=70):
    """ Create Markdown-style formatted string from a list of strings """
    wrapper = textwrap.TextWrapper(initial_indent="* ",
                                   subsequent_indent="  ",
                                   width=width)
    return '\n'.join([wrapper.fill(s) for s in list])


class JsonError(Exception):
    '''An error class for when data in the source HJSON is bad'''
    def __init__(self, path, msg):
        self.path = path
        self.msg = msg

    def __str__(self):
        return 'In hjson at {}, {}'.format(self.path, self.msg)


def get_field(path, where, data, name, expected_type=dict, optional=False, constructor=None):
    value = data.get(name)
    if value is None:
        if not optional:
            raise JsonError(path, '{}, missing {!r} field.'.format(where, name))
        return None

    if not isinstance(value, expected_type):
        raise JsonError(path,
                        '{}, the {!r} field is {!r}, but should be of type {!r}.'
                        .format(where, name, value, expected_type.__name__))

    return value if constructor is None else constructor(value)


class Upstream:
    '''A class representing the 'upstream' field in a config or lock file'''
    def __init__(self, path, data):
        # Fields: 'url', 'rev', 'only_subdir' (optional). All should be strings.
        where = 'in upstream dict'
        self.url = get_field(path, where, data, 'url', str)
        self.rev = get_field(path, where, data, 'rev', str)
        self.only_subdir = get_field(path, where, data,
                                     'only_subdir', str, optional=True)

    def as_dict(self):
        data = {'url': self.url, 'rev': self.rev}
        if self.only_subdir is not None:
            data['only_subdir'] = self.only_subdir
        return data


class PatchRepo:
    '''A class representing the 'patch_repo' field in a config file'''
    def __init__(self, path, data):
        # Fields: 'url', 'rev_base', 'rev_patched'. All should be strings.
        where = 'in patch_repo dict'
        self.url = get_field(path, where, data, 'url', str)
        self.rev_base = get_field(path, where, data, 'rev_base', str)
        self.rev_patched = get_field(path, where, data, 'rev_patched', str)


class Mapping1:
    '''A class to represent a single item in the 'mapping' field in a config file'''
    def __init__(self, from_path, to_path, patch_dir):
        self.from_path = from_path
        self.to_path = to_path
        self.patch_dir = patch_dir

    @staticmethod
    def check_path(idx, name, val):
        '''Check that a path is valid for use in a mapping.

        This spots things like '../../../foo' or '/etc/passwd'.
        '''
        val = os.path.normpath(val)
        if val.startswith('/') or val.startswith('..'):
            raise JsonError(val,
                            'Mapping entry {} has a bad path for {!r} '
                            '(must be a relative path that doesn\'t '
                            'escape the directory)'
                            .format(idx + 1, name))
        return Path(val)

    @staticmethod
    def make(path, idx, data):
        if isinstance(data, str):
            # A string S is interpreted as {from: S, to: S}.
            from_path = Mapping1.check_path(idx, 'from', data)
            to_path = from_path
            patch_dir = None
        else:
            # Otherwise, we expect a dictionary with 'from', 'to' and
            # (optionally) 'patch_dir'.
            assert isinstance(data, dict)

            def get_path(name, optional=False):
                val = get_field(path, 'in mapping entry {}'.format(idx + 1),
                                data, name,
                                expected_type=str, optional=optional)
                if val is None:
                    return None

                return Mapping1.check_path(idx, name, val)

            from_path = get_path('from')
            to_path = get_path('to')
            patch_dir = get_path('patch_dir', optional=True)

        return Mapping1(from_path, to_path, patch_dir)

    @staticmethod
    def make_default(have_patch_dir):
        '''Make a default mapping1, which copies everything straight through'''
        return Mapping1(Path('.'), Path('.'),
                        Path('.') if have_patch_dir else None)

    @staticmethod
    def apply_patch(basedir, patchfile):
        cmd = ['git', 'apply', '--directory', str(basedir), '-p1',
               str(patchfile)]
        if verbose:
            cmd += ['--verbose']
        subprocess.run(cmd, check=True)

    def import_from_upstream(self, upstream_path,
                             target_path, exclude_files, patch_dir):
        '''Copy from the upstream checkout to target_path'''
        from_path = upstream_path / self.from_path
        to_path = target_path / self.to_path

        # Make sure the target directory actually exists
        to_path.parent.mkdir(exist_ok=True, parents=True)

        # Copy src to dst recursively. For directories, we can use
        # shutil.copytree. This doesn't support files, though, so we have to
        # check for them first.
        if from_path.is_file():
            shutil.copy(str(from_path), str(to_path))
        else:
            ignore = ignore_patterns(str(upstream_path), *exclude_files)
            shutil.copytree(str(from_path), str(to_path), ignore=ignore)

        # Apply any patches to the copied files. If self.patch_dir is None,
        # there are none to apply. Otherwise, resolve it relative to patch_dir.
        if self.patch_dir is not None:
            patches = (patch_dir / self.patch_dir).glob('*.patch')
            for patch in sorted(patches):
                log.info("Applying patch {} at {}".format(patch, to_path))
                Mapping1.apply_patch(to_path, patch)


class Mapping:
    '''A class representing the 'mapping' field in a config file

    This should be a list of dicts.
    '''
    def __init__(self, items):
        self.items = items

    @staticmethod
    def make(path, data):
        items = []
        assert isinstance(data, list)
        for idx, elt in enumerate(data):
            if not (isinstance(elt, dict) or isinstance(elt, str)):
                raise JsonError(path,
                                f'Mapping element {elt!r} is not a dict.')
            items.append(Mapping1.make(path, idx, elt))

        return Mapping(items)

    def has_patch_dir(self):
        '''Check whether at least one item defines a patch dir'''
        for item in self.items:
            if item.patch_dir is not None:
                return True
        return False


class LockDesc:
    '''A class representing the contents of a lock file'''
    def __init__(self, handle):
        data = hjson.loads(handle.read(), use_decimal=True)
        self.upstream = get_field(handle.name, 'at top-level', data, 'upstream',
                                  constructor=lambda data: Upstream(handle.name, data))


class Desc:
    '''A class representing the configuration file'''

    def __init__(self, handle, desc_overrides):

        # Ensure description file matches our naming rules (otherwise we don't
        # know the name for the lockfile). This regex checks that we have the
        # right suffix and a nonempty name.
        if not re.match(r'.+\.vendor\.hjson', handle.name):
            raise ValueError("Description file names must have a .vendor.hjson suffix.")

        data = hjson.loads(handle.read(), use_decimal=True)
        where = 'at top-level'

        self.apply_overrides(data, desc_overrides)

        path = Path(handle.name)

        def take_path(p):
            return path.parent / p

        self.path = path
        self.name = get_field(path, where, data, 'name', expected_type=str)
        self.target_dir = get_field(path, where, data, 'target_dir',
                                    expected_type=str, constructor=take_path)
        self.upstream = get_field(path, where, data, 'upstream',
                                  constructor=lambda data: Upstream(path, data))
        self.patch_dir = get_field(path, where, data, 'patch_dir',
                                   optional=True, expected_type=str, constructor=take_path)
        self.patch_repo = get_field(path, where, data, 'patch_repo',
                                    optional=True,
                                    constructor=lambda data: PatchRepo(path, data))
        self.exclude_from_upstream = (get_field(path, where, data, 'exclude_from_upstream',
                                                optional=True, expected_type=list) or
                                      [])
        self.mapping = get_field(path, where, data, 'mapping', optional=True,
                                 expected_type=list,
                                 constructor=lambda data: Mapping.make(path, data))

        # Add default exclusions
        self.exclude_from_upstream += EXCLUDE_ALWAYS

        # It doesn't make sense to define a patch_repo, but not a patch_dir
        # (where should we put the patches that we get?)
        if self.patch_repo is not None and self.patch_dir is None:
            raise JsonError(path, 'Has patch_repo but not patch_dir.')

        # We don't currently support a patch_repo and a mapping (just because
        # we haven't written the code to generate the patches across subdirs
        # yet). Tracked in issue #2317.
        if self.patch_repo is not None and self.mapping is not None:
            raise JsonError(path,
                            "vendor.py doesn't currently support patch_repo "
                            "and mapping at the same time (see issue #2317).")

        # If a patch_dir is defined and there is no mapping, we will look in
        # that directory for patches and apply them in (the only) directory
        # that we copy stuff into.
        #
        # If there is a mapping check that there is a patch_dir if and only if
        # least one mapping entry uses it.
        if self.mapping is not None:
            if self.patch_dir is not None:
                if not self.mapping.has_patch_dir():
                    raise JsonError(path, 'Has patch_dir, but no mapping item uses it.')
            else:
                if self.mapping.has_patch_dir():
                    raise JsonError(path,
                                    'Has a mapping item with a patch directory, '
                                    'but there is no global patch_dir key.')

        # Check that exclude_from_upstream really is a list of strings. Most of
        # this type-checking is in the constructors for field types, but we
        # don't have a "ExcludeList" class, so have to do it explicitly here.
        for efu in self.exclude_from_upstream:
            if not isinstance(efu, str):
                raise JsonError(path,
                                'exclude_from_upstream has entry {}, which is not a string.'
                                .format(efu))

    def apply_overrides(self, desc_data, desc_overrides):
        """ Apply overrides from command line to configuration file data

        Updates are applied to the desc_data reference."""

        for key, value in desc_overrides:
            log.info("Overriding description key {!r} with value {!r}".format(
                key, value))
            ref = desc_data
            split_keys = key.split('.')
            for key_part in split_keys[:-1]:
                if key_part not in ref:
                    ref[key_part] = {}
                ref = ref[key_part]
            ref[split_keys[-1]] = value

    def lock_file_path(self):
        desc_file_stem = self.path.name.rsplit('.', 2)[0]
        return self.path.with_name(desc_file_stem + '.lock.hjson')

    def import_from_upstream(self, upstream_path):
        log.info('Copying upstream sources to {}'.format(self.target_dir))

        # Remove existing directories before importing them again
        shutil.rmtree(str(self.target_dir), ignore_errors=True)

        items = (self.mapping.items if self.mapping is not None
                 else [Mapping1.make_default(self.patch_dir is not None)])
        for map1 in items:
            map1.import_from_upstream(upstream_path,
                                      self.target_dir,
                                      self.exclude_from_upstream,
                                      self.patch_dir)


def refresh_patches(desc):
    if desc.patch_repo is None:
        log.fatal('Unable to refresh patches, patch_repo not set in config.')
        sys.exit(1)

    log.info('Refreshing patches in {}'.format(desc.patch_dir))

    # remove existing patches
    for patch in desc.patch_dir.glob('*.patch'):
        os.unlink(str(patch))

    # get current patches
    _export_patches(desc.patch_repo.url, desc.patch_dir,
                    desc.patch_repo.rev_base,
                    desc.patch_repo.rev_patched)


def _export_patches(patchrepo_clone_url, target_patch_dir, upstream_rev,
                    patched_rev):
    with tempfile.TemporaryDirectory() as clone_dir:
        clone_git_repo(patchrepo_clone_url, clone_dir, patched_rev)
        rev_range = 'origin/' + upstream_rev + '..' + 'origin/' + patched_rev
        cmd = [
            'git',
            'format-patch',
            '--no-signature',
            '--no-stat',
            '-o',
            str(target_patch_dir.resolve()),
            rev_range
        ]
        if not verbose:
            cmd += ['-q']
        subprocess.run(cmd, cwd=str(clone_dir), check=True)


def ignore_patterns(base_dir, *patterns):
    """Similar to shutil.ignore_patterns, but with support for directory excludes."""
    def _rel_to_base(path, name):
        return os.path.relpath(os.path.join(path, name), base_dir)

    def _ignore_patterns(path, names):
        ignored_names = []
        for pattern in patterns:
            pattern_matches = [
                n for n in names
                if fnmatch.fnmatch(_rel_to_base(path, n), pattern)
            ]
            ignored_names.extend(pattern_matches)
        return set(ignored_names)

    return _ignore_patterns


def clone_git_repo(repo_url, clone_dir, rev='master'):
    log.info('Cloning upstream repository %s @ %s', repo_url, rev)

    # Clone the whole repository
    cmd = ['git', 'clone', '--no-single-branch']
    if not verbose:
        cmd += ['-q']
    cmd += [repo_url, str(clone_dir)]
    subprocess.run(cmd, check=True)

    # Check out exactly the revision requested
    cmd = ['git', '-C', str(clone_dir), 'checkout', '--force', rev]
    if not verbose:
        cmd += ['-q']
    subprocess.run(cmd, check=True)

    # Get revision information
    cmd = ['git', '-C', str(clone_dir), 'rev-parse', 'HEAD']
    rev = subprocess.run(cmd,
                         stdout=subprocess.PIPE,
                         stderr=subprocess.PIPE,
                         check=True,
                         universal_newlines=True).stdout.strip()
    log.info('Cloned at revision %s', rev)
    return rev


def git_get_short_rev(clone_dir, rev):
    """ Get the shortened SHA-1 hash for a revision """
    cmd = ['git', '-C', str(clone_dir), 'rev-parse', '--short', rev]
    short_rev = subprocess.run(cmd,
                               stdout=subprocess.PIPE,
                               stderr=subprocess.PIPE,
                               check=True,
                               universal_newlines=True).stdout.strip()
    return short_rev


def git_add_commit(paths, commit_msg):
    """ Stage and commit all changes in paths"""

    assert paths
    base_dir = paths[0].parent

    # Stage all changes
    #
    # Rather than figuring out GIT_DIR properly, we cheat and use "git -C" to
    # pretend that we're running in base_dir. Of course, the elements of paths
    # are relative to our actual working directory. Rather than do anything
    # clever, we just resolve them to absolute paths as we go.
    abs_paths = [p.resolve() for p in paths]
    subprocess.run(['git', '-C', base_dir, 'add'] + abs_paths, check=True)

    cmd_commit = ['git', '-C', base_dir, 'commit', '-s', '-F', '-']
    try:
        subprocess.run(cmd_commit,
                       check=True,
                       universal_newlines=True,
                       input=commit_msg)
    except subprocess.CalledProcessError:
        log.warning("Unable to create commit. Are there no changes?")


def define_arg_type(arg):
    """Sanity-check and return a config file override argument"""
    try:
        (key, value) = [v.strip() for v in arg.split('=', 2)]
    except Exception:
        raise argparse.ArgumentTypeError(
            'unable to parse {!r}: configuration overrides must be in the form key=value'
            .format(arg))

    if key not in OVERRIDABLE_DESC_KEYS:
        raise argparse.ArgumentTypeError(
            'invalid configuration override: key {!r} cannot be overwritten'
            .format(key))
    return (key, value)


def main(argv):
    parser = argparse.ArgumentParser(prog="vendor", description=__doc__)
    parser.add_argument(
        '--update',
        '-U',
        dest='update',
        action='store_true',
        help='Update locked version of repository with upstream changes')
    parser.add_argument('--refresh-patches',
                        action='store_true',
                        help='Refresh the patches from the patch repository')
    parser.add_argument('--commit',
                        '-c',
                        action='store_true',
                        help='Commit the changes')
    parser.add_argument('--desc-override',
                        '-D',
                        dest="desc_overrides",
                        action="append",
                        type=define_arg_type,
                        default=[],
                        help='Override a setting in the description file. '
                             'Format: -Dsome.key=value. '
                             'Can be used multiple times.')
    parser.add_argument('desc_file',
                        metavar='file',
                        type=argparse.FileType('r', encoding='UTF-8'),
                        help='vendoring description file (*.vendor.hjson)')
    parser.add_argument('--verbose', '-v', action='store_true', help='Verbose')
    args = parser.parse_args()

    global verbose
    verbose = args.verbose
    if (verbose):
        log.basicConfig(format="%(levelname)s: %(message)s", level=log.DEBUG)
    else:
        log.basicConfig(format="%(levelname)s: %(message)s")

    # Load input files (desc file; lock file) and check syntax etc.
    try:
        # Load description file
        desc = Desc(args.desc_file, args.desc_overrides)
        lock_file_path = desc.lock_file_path()

        # Try to load lock file (which might not exist)
        try:
            with open(str(lock_file_path), 'r') as lock_file:
                lock = LockDesc(lock_file)
        except FileNotFoundError:
            lock = None
    except (JsonError, ValueError) as err:
        log.fatal(str(err))
        raise SystemExit(1)

    # Check for a clean working directory when commit is requested
    if args.commit:
        if not git_is_clean_workdir(desc.path.parent):
            log.fatal("A clean git working directory is required for "
                      "--commit/-c. git stash your changes and try again.")
            raise SystemExit(1)

    if lock is None and not args.update:
        log.warning("No lock file at {}, so will update upstream repo."
                    .format(str(desc.lock_file_path())))
        args.update = True

    # If we have a lock file and we're not in update mode, override desc's
    # upstream field with the one from the lock file. Keep track of whether the
    # URL differs (in which case, we can't get a shortlog)
    changed_url = False
    if lock is not None:
        changed_url = desc.upstream.url != lock.upstream.url
        if not args.update:
            desc.upstream = lock.upstream

    if args.refresh_patches:
        refresh_patches(desc)

    with tempfile.TemporaryDirectory() as clone_dir:
        # clone upstream repository
        upstream_new_rev = clone_git_repo(desc.upstream.url, clone_dir, rev=desc.upstream.rev)

        if not args.update:
            if upstream_new_rev != lock.upstream.rev:
                log.fatal(
                    "Revision mismatch. Unable to re-clone locked version of repository."
                )
                log.fatal("Attempted revision: %s", desc.upstream.rev)
                log.fatal("Re-cloned revision: %s", upstream_new_rev)
                raise SystemExit(1)

        clone_subdir = Path(clone_dir)
        if desc.upstream.only_subdir is not None:
            clone_subdir = clone_subdir / desc.upstream.only_subdir
            if not clone_subdir.is_dir():
                log.fatal("subdir '{}' does not exist in repo"
                          .format(desc.upstream.only_subdir))
                raise SystemExit(1)

        # copy selected files from upstream repo and apply patches as necessary
        desc.import_from_upstream(clone_subdir)

        # get shortlog
        get_shortlog = args.update
        if args.update:
            if lock is None:
                get_shortlog = False
                log.warning("No lock file %s: unable to summarize changes.", str(lock_file_path))
            elif changed_url:
                get_shortlog = False
                log.warning("The repository URL changed since the last run. "
                            "Unable to get log of changes.")

        shortlog = None
        if get_shortlog:
            shortlog = produce_shortlog(clone_subdir, desc.mapping,
                                        lock.upstream.rev, upstream_new_rev)

            # Ensure fully-qualified issue/PR references for GitHub repos
            gh_repo_info = github_parse_url(desc.upstream.url)
            if gh_repo_info:
                shortlog = github_qualify_references(shortlog, gh_repo_info[0],
                                                     gh_repo_info[1])

            log.info("Changes since the last import:\n" +
                     format_list_to_str(shortlog))

        # write lock file
        if args.update:
            lock_data = {}
            lock_data['upstream'] = desc.upstream.as_dict()
            lock_data['upstream']['rev'] = upstream_new_rev
            with open(str(lock_file_path), 'w', encoding='UTF-8') as f:
                f.write(LOCK_FILE_HEADER)
                hjson.dump(lock_data, f)
                f.write("\n")
                log.info("Wrote lock file %s", str(lock_file_path))

        # Commit changes
        if args.commit:
            sha_short = git_get_short_rev(clone_subdir, upstream_new_rev)

            repo_info = github_parse_url(desc.upstream.url)
            if repo_info is not None:
                sha_short = "%s/%s@%s" % (repo_info[0], repo_info[1],
                                          sha_short)

            commit_msg_subject = 'Update %s to %s' % (desc.name, sha_short)
            intro = ('Update code from {}upstream repository {} to revision {}'
                     .format(('' if desc.upstream.only_subdir is None else
                              'subdir {} in '.format(desc.upstream.only_subdir)),
                             desc.upstream.url,
                             upstream_new_rev))
            commit_msg_body = textwrap.fill(intro, width=70)

            if shortlog:
                commit_msg_body += "\n\n"
                commit_msg_body += format_list_to_str(shortlog, width=70)

            commit_msg = commit_msg_subject + "\n\n" + commit_msg_body

            commit_paths = []
            commit_paths.append(desc.target_dir)
            if args.refresh_patches:
                commit_paths.append(desc.patch_dir)
            commit_paths.append(lock_file_path)

            git_add_commit(commit_paths, commit_msg)

    log.info('Import finished')


if __name__ == '__main__':
    try:
        main(sys.argv)
    except subprocess.CalledProcessError as e:
        log.fatal("Called program '%s' returned with %d.\n"
                  "STDOUT:\n%s\n"
                  "STDERR:\n%s\n" %
                  (" ".join(e.cmd), e.returncode, e.stdout, e.stderr))
        raise
    except KeyboardInterrupt:
        log.info("Aborting operation on user request.")
        sys.exit(1)
