| #!/usr/bin/env python3 |
| # |
| # Helper script for committing data to git and pushing upstream |
| # |
| # Copyright (c) 2017, Intel Corporation. |
| # |
| # This program is free software; you can redistribute it and/or modify it |
| # under the terms and conditions of the GNU General Public License, |
| # version 2, as published by the Free Software Foundation. |
| # |
| # This program is distributed in the hope it will be useful, but WITHOUT |
| # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or |
| # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for |
| # more details. |
| # |
| import argparse |
| import glob |
| import json |
| import logging |
| import math |
| import os |
| import re |
| import sys |
| from collections import namedtuple, OrderedDict |
| from datetime import datetime, timedelta, tzinfo |
| from operator import attrgetter |
| |
| # Import oe and bitbake libs |
| scripts_path = os.path.dirname(os.path.realpath(__file__)) |
| sys.path.append(os.path.join(scripts_path, 'lib')) |
| import scriptpath |
| scriptpath.add_bitbake_lib_path() |
| scriptpath.add_oe_lib_path() |
| |
| from oeqa.utils.git import GitRepo, GitError |
| from oeqa.utils.metadata import metadata_from_bb |
| |
| |
| # Setup logging |
| logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") |
| log = logging.getLogger() |
| |
| |
| class ArchiveError(Exception): |
| """Internal error handling of this script""" |
| |
| |
| def format_str(string, fields): |
| """Format string using the given fields (dict)""" |
| try: |
| return string.format(**fields) |
| except KeyError as err: |
| raise ArchiveError("Unable to expand string '{}': unknown field {} " |
| "(valid fields are: {})".format( |
| string, err, ', '.join(sorted(fields.keys())))) |
| |
| |
| def init_git_repo(path, no_create, bare): |
| """Initialize local Git repository""" |
| path = os.path.abspath(path) |
| if os.path.isfile(path): |
| raise ArchiveError("Invalid Git repo at {}: path exists but is not a " |
| "directory".format(path)) |
| if not os.path.isdir(path) or not os.listdir(path): |
| if no_create: |
| raise ArchiveError("No git repo at {}, refusing to create " |
| "one".format(path)) |
| if not os.path.isdir(path): |
| try: |
| os.mkdir(path) |
| except (FileNotFoundError, PermissionError) as err: |
| raise ArchiveError("Failed to mkdir {}: {}".format(path, err)) |
| if not os.listdir(path): |
| log.info("Initializing a new Git repo at %s", path) |
| repo = GitRepo.init(path, bare) |
| try: |
| repo = GitRepo(path, is_topdir=True) |
| except GitError: |
| raise ArchiveError("Non-empty directory that is not a Git repository " |
| "at {}\nPlease specify an existing Git repository, " |
| "an empty directory or a non-existing directory " |
| "path.".format(path)) |
| return repo |
| |
| |
| def git_commit_data(repo, data_dir, branch, message, exclude, notes): |
| """Commit data into a Git repository""" |
| log.info("Committing data into to branch %s", branch) |
| tmp_index = os.path.join(repo.git_dir, 'index.oe-git-archive') |
| try: |
| # Create new tree object from the data |
| env_update = {'GIT_INDEX_FILE': tmp_index, |
| 'GIT_WORK_TREE': os.path.abspath(data_dir)} |
| repo.run_cmd('add .', env_update) |
| |
| # Remove files that are excluded |
| if exclude: |
| repo.run_cmd(['rm', '--cached'] + [f for f in exclude], env_update) |
| |
| tree = repo.run_cmd('write-tree', env_update) |
| |
| # Create new commit object from the tree |
| parent = repo.rev_parse(branch) |
| git_cmd = ['commit-tree', tree, '-m', message] |
| if parent: |
| git_cmd += ['-p', parent] |
| commit = repo.run_cmd(git_cmd, env_update) |
| |
| # Create git notes |
| for ref, filename in notes: |
| ref = ref.format(branch_name=branch) |
| repo.run_cmd(['notes', '--ref', ref, 'add', |
| '-F', os.path.abspath(filename), commit]) |
| |
| # Update branch head |
| git_cmd = ['update-ref', 'refs/heads/' + branch, commit] |
| if parent: |
| git_cmd.append(parent) |
| repo.run_cmd(git_cmd) |
| |
| # Update current HEAD, if we're on branch 'branch' |
| if not repo.bare and repo.get_current_branch() == branch: |
| log.info("Updating %s HEAD to latest commit", repo.top_dir) |
| repo.run_cmd('reset --hard') |
| |
| return commit |
| finally: |
| if os.path.exists(tmp_index): |
| os.unlink(tmp_index) |
| |
| |
| def expand_tag_strings(repo, name_pattern, msg_subj_pattern, msg_body_pattern, |
| keywords): |
| """Generate tag name and message, with support for running id number""" |
| keyws = keywords.copy() |
| # Tag number is handled specially: if not defined, we autoincrement it |
| if 'tag_number' not in keyws: |
| # Fill in all other fields than 'tag_number' |
| keyws['tag_number'] = '{tag_number}' |
| tag_re = format_str(name_pattern, keyws) |
| # Replace parentheses for proper regex matching |
| tag_re = tag_re.replace('(', '\(').replace(')', '\)') + '$' |
| # Inject regex group pattern for 'tag_number' |
| tag_re = tag_re.format(tag_number='(?P<tag_number>[0-9]{1,5})') |
| |
| keyws['tag_number'] = 0 |
| for existing_tag in repo.run_cmd('tag').splitlines(): |
| match = re.match(tag_re, existing_tag) |
| |
| if match and int(match.group('tag_number')) >= keyws['tag_number']: |
| keyws['tag_number'] = int(match.group('tag_number')) + 1 |
| |
| tag_name = format_str(name_pattern, keyws) |
| msg_subj= format_str(msg_subj_pattern.strip(), keyws) |
| msg_body = format_str(msg_body_pattern, keyws) |
| return tag_name, msg_subj + '\n\n' + msg_body |
| |
| |
| def parse_args(argv): |
| """Parse command line arguments""" |
| parser = argparse.ArgumentParser( |
| description="Commit data to git and push upstream", |
| formatter_class=argparse.ArgumentDefaultsHelpFormatter) |
| |
| parser.add_argument('--debug', '-D', action='store_true', |
| help="Verbose logging") |
| parser.add_argument('--git-dir', '-g', required=True, |
| help="Local git directory to use") |
| parser.add_argument('--no-create', action='store_true', |
| help="If GIT_DIR is not a valid Git repository, do not " |
| "try to create one") |
| parser.add_argument('--bare', action='store_true', |
| help="Initialize a bare repository when creating a " |
| "new one") |
| parser.add_argument('--push', '-p', nargs='?', default=False, const=True, |
| help="Push to remote") |
| parser.add_argument('--branch-name', '-b', |
| default='{hostname}/{branch}/{machine}', |
| help="Git branch name (pattern) to use") |
| parser.add_argument('--no-tag', action='store_true', |
| help="Do not create Git tag") |
| parser.add_argument('--tag-name', '-t', |
| default='{hostname}/{branch}/{machine}/{commit_count}-g{commit}/{tag_number}', |
| help="Tag name (pattern) to use") |
| parser.add_argument('--commit-msg-subject', |
| default='Results of {branch}:{commit} on {hostname}', |
| help="Subject line (pattern) to use in the commit message") |
| parser.add_argument('--commit-msg-body', |
| default='branch: {branch}\ncommit: {commit}\nhostname: {hostname}', |
| help="Commit message body (pattern)") |
| parser.add_argument('--tag-msg-subject', |
| default='Test run #{tag_number} of {branch}:{commit} on {hostname}', |
| help="Subject line (pattern) of the tag message") |
| parser.add_argument('--tag-msg-body', |
| default='', |
| help="Tag message body (pattern)") |
| parser.add_argument('--exclude', action='append', default=[], |
| help="Glob to exclude files from the commit. Relative " |
| "to DATA_DIR. May be specified multiple times") |
| parser.add_argument('--notes', nargs=2, action='append', default=[], |
| metavar=('GIT_REF', 'FILE'), |
| help="Add a file as a note under refs/notes/GIT_REF. " |
| "{branch_name} in GIT_REF will be expanded to the " |
| "actual target branch name (specified by " |
| "--branch-name). This option may be specified " |
| "multiple times.") |
| parser.add_argument('data_dir', metavar='DATA_DIR', |
| help="Data to commit") |
| return parser.parse_args(argv) |
| |
| def get_nested(d, list_of_keys): |
| try: |
| for k in list_of_keys: |
| d = d[k] |
| return d |
| except KeyError: |
| return "" |
| |
| def main(argv=None): |
| """Script entry point""" |
| args = parse_args(argv) |
| if args.debug: |
| log.setLevel(logging.DEBUG) |
| |
| try: |
| if not os.path.isdir(args.data_dir): |
| raise ArchiveError("Not a directory: {}".format(args.data_dir)) |
| |
| data_repo = init_git_repo(args.git_dir, args.no_create, args.bare) |
| |
| # Get keywords to be used in tag and branch names and messages |
| metadata = metadata_from_bb() |
| keywords = {'hostname': get_nested(metadata, ['hostname']), |
| 'branch': get_nested(metadata, ['layers', 'meta', 'branch']), |
| 'commit': get_nested(metadata, ['layers', 'meta', 'commit']), |
| 'commit_count': get_nested(metadata, ['layers', 'meta', 'commit_count']), |
| 'machine': get_nested(metadata, ['config', 'MACHINE'])} |
| |
| # Expand strings early in order to avoid getting into inconsistent |
| # state (e.g. no tag even if data was committed) |
| commit_msg = format_str(args.commit_msg_subject.strip(), keywords) |
| commit_msg += '\n\n' + format_str(args.commit_msg_body, keywords) |
| branch_name = format_str(args.branch_name, keywords) |
| tag_name = None |
| if not args.no_tag and args.tag_name: |
| tag_name, tag_msg = expand_tag_strings(data_repo, args.tag_name, |
| args.tag_msg_subject, |
| args.tag_msg_body, keywords) |
| |
| # Commit data |
| commit = git_commit_data(data_repo, args.data_dir, branch_name, |
| commit_msg, args.exclude, args.notes) |
| |
| # Create tag |
| if tag_name: |
| log.info("Creating tag %s", tag_name) |
| data_repo.run_cmd(['tag', '-a', '-m', tag_msg, tag_name, commit]) |
| |
| # Push data to remote |
| if args.push: |
| cmd = ['push', '--tags'] |
| # If no remote is given we push with the default settings from |
| # gitconfig |
| if args.push is not True: |
| notes_refs = ['refs/notes/' + ref.format(branch_name=branch_name) |
| for ref, _ in args.notes] |
| cmd.extend([args.push, branch_name] + notes_refs) |
| log.info("Pushing data to remote") |
| data_repo.run_cmd(cmd) |
| |
| except ArchiveError as err: |
| log.error(str(err)) |
| return 1 |
| |
| return 0 |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |