|  | #!/usr/bin/env python3 | 
|  | # Copyright lowRISC contributors. | 
|  | # Licensed under the Apache License, Version 2.0, see LICENSE for details. | 
|  | # SPDX-License-Identifier: Apache-2.0 | 
|  |  | 
|  | 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 | 
|  |  | 
|  | DESC = """vendor, copy source code from upstream into this repository""" | 
|  |  | 
|  | EXCLUDE_ALWAYS = ['.git'] | 
|  |  | 
|  | LOCK_FILE_HEADER = """// Copyright lowRISC contributors. | 
|  | // 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. | 
|  |  | 
|  | """ | 
|  |  | 
|  | 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=git_workdir, | 
|  | check=True, | 
|  | stdout=subprocess.PIPE, | 
|  | stderr=subprocess.PIPE).stdout.strip() | 
|  | return not modified_files | 
|  |  | 
|  |  | 
|  | def path_resolve(path, base_dir=Path.cwd()): | 
|  | """Create an absolute path. Relative paths are resolved using base_dir as base.""" | 
|  |  | 
|  | if isinstance(path, str): | 
|  | path = Path(path) | 
|  |  | 
|  | if path.is_absolute(): | 
|  | return path | 
|  |  | 
|  | return (base_dir / path).resolve() | 
|  |  | 
|  |  | 
|  | 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, l) for l 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, old_rev, new_rev): | 
|  | """ Produce a list of changes between two revisions, one revision per line | 
|  |  | 
|  | Merges are excluded""" | 
|  | cmd = [ | 
|  | 'git', '-C', | 
|  | str(clone_dir), 'log', '--pretty=format:%s (%aN)', '--no-merges', | 
|  | old_rev + '..' + new_rev, '.' | 
|  | ] | 
|  | try: | 
|  | proc = subprocess.run(cmd, | 
|  | cwd=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]) | 
|  |  | 
|  |  | 
|  | def refresh_patches(desc): | 
|  | if not 'patch_repo' in desc: | 
|  | log.fatal('Unable to refresh patches, patch_repo not set in config.') | 
|  | sys.exit(1) | 
|  |  | 
|  | patch_dir_abs = path_resolve(desc['patch_dir'], desc['_base_dir']) | 
|  | log.info('Refreshing patches in %s' % (str(patch_dir_abs), )) | 
|  |  | 
|  | # remove existing patches | 
|  | for patch in patch_dir_abs.glob('*.patch'): | 
|  | os.unlink(str(patch)) | 
|  |  | 
|  | # get current patches | 
|  | _export_patches(desc['patch_repo']['url'], patch_dir_abs, | 
|  | desc['patch_repo']['rev_base'], | 
|  | desc['patch_repo']['rev_patched']) | 
|  |  | 
|  |  | 
|  | def _export_patches(patchrepo_clone_url, target_patch_dir, upstream_rev, | 
|  | patched_rev): | 
|  | clone_dir = Path(tempfile.mkdtemp()) | 
|  | try: | 
|  | clone_git_repo(patchrepo_clone_url, clone_dir, patched_rev) | 
|  | rev_range = 'origin/' + upstream_rev + '..' + 'origin/' + patched_rev | 
|  | cmd = ['git', 'format-patch', '-o', str(target_patch_dir), rev_range] | 
|  | if not verbose: | 
|  | cmd += ['-q'] | 
|  | subprocess.run(cmd, cwd=clone_dir, check=True) | 
|  |  | 
|  | finally: | 
|  | shutil.rmtree(str(clone_dir), ignore_errors=True) | 
|  |  | 
|  |  | 
|  | def import_from_upstream(upstream_path, target_path, exclude_files=[]): | 
|  | log.info('Copying upstream sources to %s', target_path) | 
|  | # remove existing directories before importing them again | 
|  | shutil.rmtree(str(target_path), ignore_errors=True) | 
|  |  | 
|  | # import new contents for rtl directory | 
|  | _cp_from_upstream(upstream_path, target_path, exclude_files) | 
|  |  | 
|  |  | 
|  | def apply_patch(basedir, patchfile, strip_level=1): | 
|  | cmd = ['git', 'apply', '-p' + str(strip_level), patchfile] | 
|  | if verbose: | 
|  | cmd += ['--verbose'] | 
|  | subprocess.run(cmd, cwd=basedir, check=True) | 
|  |  | 
|  |  | 
|  | def clone_git_repo(repo_url, clone_dir, rev='master'): | 
|  | log.info('Cloning upstream repository %s @ %s', repo_url, rev) | 
|  |  | 
|  | cmd = [ | 
|  | 'git', 'clone', '--no-single-branch', '-b', rev, repo_url, clone_dir | 
|  | ] | 
|  | 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(repo_base, paths, commit_msg): | 
|  | """ Stage and commit all changes in paths""" | 
|  |  | 
|  | # Stage all changes | 
|  | for p in paths: | 
|  | cmd_add = ['git', '-C', str(repo_base), 'add', str(p)] | 
|  | subprocess.run(cmd_add, check=True) | 
|  |  | 
|  | cmd_commit = ['git', '-C', str(repo_base), 'commit', '-s', '-F', '-'] | 
|  | try: | 
|  | subprocess.run(cmd_commit, | 
|  | check=True, | 
|  | universal_newlines=True, | 
|  | input=commit_msg) | 
|  | except subprocess.CalledProcessError as e: | 
|  | log.warning("Unable to create commit. Are there no changes?") | 
|  |  | 
|  |  | 
|  | 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 _cp_from_upstream(src, dest, exclude=[]): | 
|  | shutil.copytree(str(src), | 
|  | str(dest), | 
|  | ignore=ignore_patterns(str(src), *exclude)) | 
|  |  | 
|  |  | 
|  | def main(argv): | 
|  | parser = argparse.ArgumentParser(prog="vendor", description=DESC) | 
|  | 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_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") | 
|  |  | 
|  | desc_file_path = Path(args.desc_file.name).resolve() | 
|  | vendor_file_base_dir = desc_file_path.parent | 
|  |  | 
|  | # Precondition: Ensure description file matches our naming rules | 
|  | if not str(desc_file_path).endswith('.vendor.hjson'): | 
|  | log.fatal("Description file names must have a .vendor.hjson suffix.") | 
|  | raise SystemExit(1) | 
|  |  | 
|  | # Precondition: Check for a clean working directory when commit is requested | 
|  | if args.commit: | 
|  | if not git_is_clean_workdir(vendor_file_base_dir): | 
|  | log.fatal("A clean git working directory is required for " | 
|  | "--commit/-c. git stash your changes and try again.") | 
|  | raise SystemExit(1) | 
|  |  | 
|  | # Load description file | 
|  | try: | 
|  | desc = hjson.loads(args.desc_file.read(), use_decimal=True) | 
|  | except ValueError: | 
|  | raise SystemExit(sys.exc_info()[1]) | 
|  | desc['_base_dir'] = vendor_file_base_dir | 
|  |  | 
|  | # Load lock file contents (if possible) | 
|  | desc_file_path_str = str(desc_file_path) | 
|  | lock_file_path = Path( | 
|  | desc_file_path_str[:desc_file_path_str.find('.vendor.hjson')] + | 
|  | '.lock.hjson') | 
|  | try: | 
|  | with open(lock_file_path, 'r') as f: | 
|  | lock = hjson.loads(f.read(), use_decimal=True) | 
|  | except FileNotFoundError: | 
|  | log.warning( | 
|  | "Unable to read lock file %s. Assuming this is the first import.", | 
|  | lock_file_path) | 
|  | lock = None | 
|  |  | 
|  | if args.refresh_patches: | 
|  | refresh_patches(desc) | 
|  |  | 
|  | clone_dir = Path(tempfile.mkdtemp()) | 
|  | try: | 
|  | # clone upstream repository | 
|  | upstream_new_rev = clone_git_repo(desc['upstream']['url'], clone_dir, | 
|  | desc['upstream']['rev']) | 
|  |  | 
|  | upstream_only_subdir = '' | 
|  | clone_subdir = clone_dir | 
|  | if 'only_subdir' in desc['upstream']: | 
|  | upstream_only_subdir = desc['upstream']['only_subdir'] | 
|  | clone_subdir = clone_dir / upstream_only_subdir | 
|  | if not clone_subdir.is_dir(): | 
|  | log.fatal("subdir '%s' does not exist in repo", upstream_only_subdir) | 
|  | raise SystemExit(1) | 
|  |  | 
|  |  | 
|  | # apply patches to upstream sources | 
|  | if 'patch_dir' in desc: | 
|  | patches = path_resolve(desc['patch_dir'], | 
|  | vendor_file_base_dir).glob('*.patch') | 
|  | for patch in sorted(patches): | 
|  | log.info("Applying patch %s" % str(patch)) | 
|  | apply_patch(clone_subdir, str(patch)) | 
|  |  | 
|  | # import selected (patched) files from upstream repo | 
|  | exclude_files = [] | 
|  | if 'exclude_from_upstream' in desc: | 
|  | exclude_files += desc['exclude_from_upstream'] | 
|  | exclude_files += EXCLUDE_ALWAYS | 
|  |  | 
|  | import_from_upstream( | 
|  | clone_subdir, path_resolve(desc['target_dir'], vendor_file_base_dir), | 
|  | exclude_files) | 
|  |  | 
|  | # get shortlog | 
|  | get_shortlog = True | 
|  | if not lock: | 
|  | get_shortlog = False | 
|  | log.warning( | 
|  | "No lock file exists. Unable to get the log of changes.") | 
|  | elif lock['upstream']['url'] != desc['upstream']['url']: | 
|  | get_shortlog = False | 
|  | log.warning( | 
|  | "The repository URL changed since the last run. Unable to get log of changes." | 
|  | ) | 
|  | elif upstream_new_rev == lock['upstream']['rev']: | 
|  | get_shortlog = False | 
|  | log.warning("Re-importing upstream revision %s", upstream_new_rev) | 
|  |  | 
|  | shortlog = None | 
|  | if get_shortlog: | 
|  | shortlog = produce_shortlog(clone_subdir, 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 | 
|  | lock = {} | 
|  | lock['upstream'] = desc['upstream'] | 
|  | lock['upstream']['rev'] = upstream_new_rev | 
|  | with open(lock_file_path, 'w', encoding='UTF-8') as f: | 
|  | f.write(LOCK_FILE_HEADER) | 
|  | hjson.dump(lock, f) | 
|  | f.write("\n") | 
|  | log.info("Wrote lock file %s", 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) | 
|  | subdir_msg = ' ' | 
|  | if upstream_only_subdir: | 
|  | subdir_msg = ' subdir %s in ' % upstream_only_subdir | 
|  | intro = 'Update code from%supstream repository %s to revision %s' % ( | 
|  | subdir_msg, 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( | 
|  | path_resolve(desc['target_dir'], vendor_file_base_dir)) | 
|  | if args.refresh_patches: | 
|  | commit_paths.append( | 
|  | path_resolve(desc['patch_dir'], vendor_file_base_dir)) | 
|  | commit_paths.append(lock_file_path) | 
|  |  | 
|  | git_add_commit(vendor_file_base_dir, commit_paths, commit_msg) | 
|  |  | 
|  | finally: | 
|  | shutil.rmtree(str(clone_dir), ignore_errors=True) | 
|  |  | 
|  | 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 |