diff options
author | Sasha Levin <sasha.levin@oracle.com> | 2015-11-04 22:57:20 -0500 |
---|---|---|
committer | Sasha Levin <sasha.levin@oracle.com> | 2015-11-04 22:57:20 -0500 |
commit | 421ff2cd2d55d3b9253c0a9c42bbd109ec86fdd4 (patch) | |
tree | c15ed682517137bbffb5292e6803493220b4d6d5 | |
parent | 07d27828aae06a38e9be677f3eb63a96e376dd4a (diff) | |
download | stable-tools-421ff2cd2d55d3b9253c0a9c42bbd109ec86fdd4.tar.gz |
stable-deps
Build a list of commit dependencies to apply a given commit.
Signed-off-by: Sasha Levin <sasha.levin@oracle.com>
-rw-r--r-- | README | 12 | ||||
-rwxr-xr-x | stable-deps | 19 | ||||
-rwxr-xr-x | stable-deps.py | 790 |
3 files changed, 821 insertions, 0 deletions
@@ -104,3 +104,15 @@ and: - Tagged for stable and which other stable branches they are present in. - Not tagged for stable, but are present in other stable branches. + + +8) stable deps <commit sha1> + +Build a list of dependencies to apply the provided commit cleanly. + +This is useful when looking into whether a commit should be backported to the +current branch, and if so, which commits does it depend on. + +Once the dependency list is provided, it should be easy to decide whether the +commits should be pulled in as well, or whether the commit should be backported +by small changes to the commit itself (or both). diff --git a/stable-deps b/stable-deps new file mode 100755 index 0000000..1b2f8db --- /dev/null +++ b/stable-deps @@ -0,0 +1,19 @@ +#!/bin/bash +# +# (Try to) Show the dependency list for applying a given commit on the current +# branch. +# + +function handle_one { + stable commit-in-tree $1 + if [ $? -eq 1 ]; then + return + fi + + echo $1 + for i in $(stable-deps.py $1); do + handle_one $i + done +} + +handle_one $1 diff --git a/stable-deps.py b/stable-deps.py new file mode 100755 index 0000000..d28c85e --- /dev/null +++ b/stable-deps.py @@ -0,0 +1,790 @@ +#!/usr/bin/env python +# +# git-deps - automatically detect dependencies between git commits +# Copyright (C) 2013 Adam Spiers <git@adamspiers.org> +# +# The software in this repository is free software: you can redistribute +# it and/or modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation, either version 2 of the +# License, or (at your option) any later version. +# +# This software is distributed in the hope that 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. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from __future__ import print_function + +import argparse +import json +import logging +import os +import re +import sys +import subprocess +import types +from textwrap import dedent, wrap + + +def abort(msg, exitcode=1): + print(msg, file=sys.stderr) + sys.exit(exitcode) + +try: + import pygit2 +except ImportError: + msg = "pygit2 not installed; aborting." + install_guide = None + import platform + if platform.system() == 'Linux': + distro, version, d_id = platform.linux_distribution() + distro = distro.strip() # why are there trailing spaces?? + if distro == 'openSUSE': + install_guide = \ + "You should be able to install it with something like:\n\n" \ + " sudo zypper install python-pygit2" + + if install_guide is None: + msg += "\n\nIf you figure out a way to install it on your platform,\n" \ + "please submit a new issue with the details at:\n\n" \ + " https://github.com/aspiers/git-config/issues/new\n\n" \ + "so that it can be documented to help other users." + else: + msg += "\n\n" + install_guide + abort(msg) + + +class DependencyListener(object): + """Class for listening to result events generated by + DependencyDetector. Add an instance of this class to a + DependencyDetector instance via DependencyDetector.add_listener(). + """ + + def __init__(self, options): + self.options = options + + def set_detector(self, detector): + self.detector = detector + + def repo(self): + return self.detector.repo + + def new_commit(self, commit): + pass + + def new_dependent(self, dependent): + pass + + def new_dependency(self, dependent, dependency, path, line_num): + pass + + def new_path(self, dependent, dependency, path, line_num): + pass + + def new_line(self, dependent, dependency, path, line_num): + pass + + def dependent_done(self, dependent, dependencies): + pass + + def all_done(self): + pass + + +class CLIDependencyListener(DependencyListener): + """Dependency listener for use when running in CLI mode. + + This allows us to output dependencies as they are discovered, + rather than waiting for all dependencies to be discovered before + outputting anything; the latter approach can make the user wait + too long for useful output if recursion is enabled. + """ + + def new_dependency(self, dependent, dependency, path, line_num): + dependent_sha1 = dependent.hex + dependency_sha1 = dependency.hex + + if self.options.recurse: + if self.options.log: + print("%s depends on:" % dependent_sha1) + else: + print("%s %s" % (dependent_sha1, dependency_sha1)) + else: + if not self.options.log: + print(dependency_sha1) + + if self.options.log: + cmd = [ + 'git', + '--no-pager', + '-c', 'color.ui=always', + 'log', '-n1', + dependency_sha1 + ] + print(subprocess.check_output(cmd)) + # dependency = detector.get_commit(dependency_sha1) + # print(dependency.message + "\n") + + # for path in self.dependencies[dependency]: + # print(" %s" % path) + # keys = sorted(self.dependencies[dependency][path].keys() + # print(" %s" % ", ".join(keys))) + + +class JSONDependencyListener(DependencyListener): + """Dependency listener for use when compiling graph data in a JSON + format which can be consumed by WebCola / d3. Each new commit has + to be added to a 'commits' array. + """ + + def __init__(self, options): + super(JSONDependencyListener, self).__init__(options) + + # Map commit names to indices in the commits array. This is used + # to avoid the risk of duplicates in the commits array, which + # could happen when recursing, since multiple commits could + # potentially depend on the same commit. + self._commits = {} + + self._json = { + 'commits': [], + 'dependencies': [], + } + + def get_commit(self, sha1): + i = self._commits[sha1] + return self._json['commits'][i] + + def add_commit(self, commit): + """Adds the commit to the commits array if it doesn't already exist, + and returns the commit's index in the array. + """ + sha1 = commit.hex + if sha1 in self._commits: + return self._commits[sha1] + title, separator, body = commit.message.partition("\n") + commit = { + 'explored': False, + 'sha1': sha1, + 'name': GitUtils.abbreviate_sha1(sha1), + 'describe': GitUtils.describe(sha1), + 'refs': GitUtils.refs_to(sha1, self.repo()), + 'author_name': commit.author.name, + 'author_mail': commit.author.email, + 'author_time': commit.author.time, + 'author_offset': commit.author.offset, + 'committer_name': commit.committer.name, + 'committer_mail': commit.committer.email, + 'committer_time': commit.committer.time, + 'committer_offset': commit.committer.offset, + # 'message': commit.message, + 'title': title, + 'separator': separator, + 'body': body.lstrip("\n"), + } + self._json['commits'].append(commit) + self._commits[sha1] = len(self._json['commits']) - 1 + return self._commits[sha1] + + def add_link(self, source, target): + self._json['dependencies'].append + + def new_commit(self, commit): + self.add_commit(commit) + + def new_dependency(self, parent, child, path, line_num): + ph = parent.hex + ch = child.hex + + new_dep = { + 'parent': ph, + 'child': ch, + } + + if self.options.log: + pass # FIXME + + self._json['dependencies'].append(new_dep) + + def dependent_done(self, dependent, dependencies): + commit = self.get_commit(dependent.hex) + commit['explored'] = True + + def json(self): + return self._json + + +class GitUtils(object): + @classmethod + def abbreviate_sha1(cls, sha1): + """Uniquely abbreviates the given SHA1.""" + + # For now we invoke git-rev-parse(1), but hopefully eventually + # we will be able to do this via pygit2. + cmd = ['git', 'rev-parse', '--short', sha1] + # cls.logger.debug(" ".join(cmd)) + out = subprocess.check_output(cmd).strip() + # cls.logger.debug(out) + return out + + @classmethod + def describe(cls, sha1): + """Returns a human-readable representation of the given SHA1.""" + + # For now we invoke git-describe(1), but eventually we will be + # able to do this via pygit2, since libgit2 already provides + # an API for this: + # https://github.com/libgit2/pygit2/pull/459#issuecomment-68866929 + # https://github.com/libgit2/libgit2/pull/2592 + cmd = [ + 'git', 'describe', + '--all', # look for tags and branches + '--long', # remotes/github/master-0-g2b6d591 + # '--contains', + # '--abbrev', + sha1 + ] + # cls.logger.debug(" ".join(cmd)) + out = None + try: + out = subprocess.check_output(cmd, stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + if e.output.find('No tags can describe') != -1: + return '' + raise + + out = out.strip() + out = re.sub(r'^(heads|tags|remotes)/', '', out) + # We already have the abbreviated SHA1 from abbreviate_sha1() + out = re.sub(r'-g[0-9a-f]{7,}$', '', out) + # cls.logger.debug(out) + return out + + @classmethod + def refs_to(cls, sha1, repo): + """Returns all refs pointing to the given SHA1.""" + matching = [] + for refname in repo.listall_references(): + symref = repo.lookup_reference(refname) + dref = symref.resolve() + oid = dref.target + commit = repo.get(oid) + if commit.hex == sha1: + matching.append(symref.shorthand) + + return matching + +class InvalidCommitish(StandardError): + def __init__(self, commitish): + self.commitish = commitish + + def message(self): + return "Couldn't resolve commitish %s" % self.commitish + + +class DependencyDetector(object): + """Class for automatically detecting dependencies between git commits. + A dependency is inferred by diffing the commit with each of its + parents, and for each resulting hunk, performing a blame to see + which commit was responsible for introducing the lines to which + the hunk was applied. + + Dependencies can be traversed recursively, building a dependency + tree represented (conceptually) by a list of edges. + """ + + def __init__(self, options, repo_path=None, logger=None): + self.options = options + + if logger is None: + self.logger = self.default_logger() + + if repo_path is None: + try: + repo_path = pygit2.discover_repository('.') + except KeyError: + abort("Couldn't find a repository in the current directory.") + + self.repo = pygit2.Repository(repo_path) + + # Nested dict mapping dependents -> dependencies -> files + # causing that dependency -> numbers of lines within that file + # causing that dependency. The first two levels form edges in + # the dependency graph, and the latter two tell us what caused + # those edges. + self.dependencies = {} + + # A TODO list (queue) and dict of dependencies which haven't + # yet been recursively followed. Only useful when recursing. + self.todo = [] + self.todo_d = {} + + # An ordered list and dict of commits whose dependencies we + # have already detected. + self.done = [] + self.done_d = {} + + # A cache mapping SHA1s to commit objects + self.commits = {} + + # Memoization for branch_contains() + self.branch_contains_cache = {} + + # Callbacks to be invoked when a new dependency has been + # discovered. + self.listeners = [] + + def add_listener(self, listener): + if not isinstance(listener, DependencyListener): + raise RuntimeError("Listener must be a DependencyListener") + self.listeners.append(listener) + listener.set_detector(self) + + def notify_listeners(self, event, *args): + for listener in self.listeners: + fn = getattr(listener, event) + fn(*args) + + def default_logger(self): + if not self.options.debug: + return logging.getLogger(self.__class__.__name__) + + log_format = '%(asctime)-15s %(levelname)-6s %(message)s' + date_format = '%b %d %H:%M:%S' + formatter = logging.Formatter(fmt=log_format, datefmt=date_format) + handler = logging.StreamHandler(stream=sys.stdout) + handler.setFormatter(formatter) + # logger = logging.getLogger(__name__) + logger = logging.getLogger(self.__class__.__name__) + logger.setLevel(logging.DEBUG) + logger.addHandler(handler) + return logger + + def get_commit(self, rev): + if rev in self.commits: + return self.commits[rev] + + try: + self.commits[rev] = self.repo.revparse_single(rev) + except (KeyError, ValueError): + raise InvalidCommitish(rev) + + return self.commits[rev] + + def find_dependencies(self, dependent_rev, recurse=None): + """Find all dependencies of the given revision, recursively traversing + the dependency tree if requested. + """ + if recurse is None: + recurse = self.options.recurse + + try: + dependent = self.get_commit(dependent_rev) + except InvalidCommitish as e: + abort(e.message()) + + self.todo.append(dependent) + self.todo_d[dependent.hex] = True + + while self.todo: + sha1s = [commit.hex[:8] for commit in self.todo] + self.logger.debug("TODO list: %s" % " ".join(sha1s)) + dependent = self.todo.pop(0) + del self.todo_d[dependent.hex] + self.logger.debug("Processing %s from TODO list" % + dependent.hex[:8]) + self.notify_listeners('new_commit', dependent) + + for parent in dependent.parents: + self.find_dependencies_with_parent(dependent, parent) + self.done.append(dependent.hex) + self.done_d[dependent.hex] = True + self.logger.debug("Found all dependencies for %s" % + dependent.hex[:8]) + # A commit won't have any dependencies if it only added new files + dependencies = self.dependencies.get(dependent.hex, {}) + self.notify_listeners('dependent_done', dependent, dependencies) + + self.notify_listeners('all_done') + + def find_dependencies_with_parent(self, dependent, parent): + """Find all dependencies of the given revision caused by the given + parent commit. This will be called multiple times for merge + commits which have multiple parents. + """ + self.logger.debug(" Finding dependencies of %s via parent %s" % + (dependent.hex[:8], parent.hex[:8])) + diff = self.repo.diff(parent, dependent, + context_lines=self.options.context_lines) + for patch in diff: + path = patch.old_file_path + self.logger.debug(" Examining hunks in %s" % path) + for hunk in patch.hunks: + self.blame_hunk(dependent, parent, path, hunk) + + def blame_hunk(self, dependent, parent, path, hunk): + """Run git blame on the parts of the hunk which exist in the older + commit in the diff. The commits generated by git blame are + the commits which the newer commit in the diff depends on, + because without the lines from those commits, the hunk would + not apply correctly. + """ + first_line_num = hunk.old_start + line_range_before = "-%d,%d" % (hunk.old_start, hunk.old_lines) + line_range_after = "+%d,%d" % (hunk.new_start, hunk.new_lines) + self.logger.debug(" Blaming hunk %s @ %s" % + (line_range_before, parent.hex[:8])) + + if not self.tree_lookup(path, parent): + # This is probably because dependent added a new directory + # which was not previously in the parent. + return + + cmd = [ + 'git', 'blame', + '--porcelain', + '-L', "%d,+%d" % (hunk.old_start, hunk.old_lines), + parent.hex, '--', path + ] + blame = subprocess.check_output(cmd) + + dependent_sha1 = dependent.hex + if dependent_sha1 not in self.dependencies: + self.logger.debug(' New dependent: %s (%s)' % + (dependent_sha1[:8], self.oneline(dependent))) + self.dependencies[dependent_sha1] = {} + self.notify_listeners('new_dependent', dependent) + + line_to_culprit = {} + + for line in blame.split('\n'): + # self.logger.debug(' !' + line.rstrip()) + m = re.match('^([0-9a-f]{40}) (\d+) (\d+)( \d+)?$', line) + if not m: + continue + dependency_sha1, orig_line_num, line_num = m.group(1, 2, 3) + line_num = int(line_num) + dependency = self.get_commit(dependency_sha1) + line_to_culprit[line_num] = dependency.hex + + if self.is_excluded(dependency): + self.logger.debug( + ' Excluding dependency %s from line %s (%s)' % + (dependency_sha1[:8], line_num, + self.oneline(dependency))) + continue + + if dependency_sha1 not in self.dependencies[dependent_sha1]: + if dependency_sha1 in self.todo_d: + self.logger.debug( + ' Dependency %s via line %s already in TODO' % + (dependency_sha1[:8], line_num,)) + continue + + if dependency_sha1 in self.done_d: + self.logger.debug( + ' Dependency %s via line %s already done' % + (dependency_sha1[:8], line_num,)) + continue + + self.logger.debug( + ' New dependency %s via line %s (%s)' % + (dependency_sha1[:8], line_num, self.oneline(dependency))) + self.dependencies[dependent_sha1][dependency_sha1] = {} + self.notify_listeners('new_commit', dependency) + self.notify_listeners('new_dependency', + dependent, dependency, path, line_num) + if dependency_sha1 not in self.dependencies: + if self.options.recurse: + self.todo.append(dependency) + self.todo_d[dependency.hex] = True + self.logger.debug(' added to TODO') + + dep_sources = self.dependencies[dependent_sha1][dependency_sha1] + + if path not in dep_sources: + dep_sources[path] = {} + self.notify_listeners('new_path', + dependent, dependency, path, line_num) + + if line_num in dep_sources[path]: + abort("line %d already found when blaming %s:%s" % + (line_num, parent.hex[:8], path)) + + dep_sources[path][line_num] = True + self.notify_listeners('new_line', + dependent, dependency, path, line_num) + + diff_format = ' |%8.8s %5s %s%s' + hunk_header = '@@ %s %s @@' % (line_range_before, line_range_after) + self.logger.debug(diff_format % ('--------', '-----', '', hunk_header)) + line_num = hunk.old_start + for mode, line in hunk.lines: + if mode == '+': + rev = ln = '' + else: + rev = line_to_culprit[line_num] + ln = line_num + line_num += 1 + self.logger.debug(diff_format % (rev, ln, mode, line.rstrip())) + + def oneline(self, commit): + return commit.message.split('\n', 1)[0] + + def is_excluded(self, commit): + if self.options.exclude_commits is not None: + for exclude in self.options.exclude_commits: + if self.branch_contains(commit, exclude): + return True + return False + + def branch_contains(self, commit, branch): + sha1 = commit.hex + branch_commit = self.get_commit(branch) + branch_sha1 = branch_commit.hex + self.logger.debug(" Does %s (%s) contain %s?" % + (branch, branch_sha1[:8], sha1[:8])) + + if sha1 not in self.branch_contains_cache: + self.branch_contains_cache[sha1] = {} + if branch_sha1 in self.branch_contains_cache[sha1]: + memoized = self.branch_contains_cache[sha1][branch_sha1] + self.logger.debug(" %s (memoized)" % memoized) + return memoized + + cmd = ['git', 'merge-base', sha1, branch_sha1] + # self.logger.debug(" ".join(cmd)) + out = subprocess.check_output(cmd).strip() + self.logger.debug(" merge-base returned: %s" % out[:8]) + result = out == sha1 + self.logger.debug(" %s" % result) + self.branch_contains_cache[sha1][branch_sha1] = result + return result + + def tree_lookup(self, target_path, commit): + """Navigate to the tree or blob object pointed to by the given target + path for the given commit. This is necessary because each git + tree only contains entries for the directory it refers to, not + recursively for all subdirectories. + """ + segments = target_path.split("/") + tree_or_blob = commit.tree + path = '' + while segments: + dirent = segments.pop(0) + if isinstance(tree_or_blob, pygit2.Tree): + if dirent in tree_or_blob: + tree_or_blob = self.repo[tree_or_blob[dirent].oid] + # self.logger.debug('%s in %s' % (dirent, path)) + if path: + path += '/' + path += dirent + else: + # This is probably because we were called on a + # commit whose parent added a new directory. + self.logger.debug(' %s not in %s in %s' % + (dirent, path, commit.hex[:8])) + return None + else: + self.logger.debug(' %s not a tree in %s' % + (tree_or_blob, commit.hex[:8])) + return None + return tree_or_blob + + def edges(self): + return [ + [(dependent, dependency) + for dependency in self.dependencies[dependent]] + for dependent in self.dependencies.keys() + ] + + +def parse_args(): + parser = argparse.ArgumentParser( + description='Auto-detects commits on which the given ' + 'commit(s) depend.', + usage='%(prog)s [options] COMMIT-ISH [COMMIT-ISH...]', + add_help=False + ) + parser.add_argument('-h', '--help', action='help', + help='Show this help message and exit') + parser.add_argument('-l', '--log', dest='log', action='store_true', + help='Show commit logs for calculated dependencies') + parser.add_argument('-j', '--json', dest='json', action='store_true', + help='Output dependencies as JSON') + parser.add_argument('-s', '--serve', dest='serve', action='store_true', + help='Run a web server for visualizing the ' + 'dependency graph') + parser.add_argument('-b', '--bind-ip', dest='bindaddr', type=str, + metavar='IP', default='127.0.0.1', + help='IP address for webserver to bind to [%(default)s]') + parser.add_argument('-p', '--port', dest='port', type=int, metavar='PORT', + default=5000, + help='Port number for webserver [%(default)s]') + parser.add_argument('-r', '--recurse', dest='recurse', action='store_true', + help='Follow dependencies recursively') + parser.add_argument('-e', '--exclude-commits', dest='exclude_commits', + action='append', metavar='COMMITISH', + help='Exclude commits which are ancestors of the ' + 'given COMMITISH (can be repeated)') + parser.add_argument('-c', '--context-lines', dest='context_lines', + type=int, metavar='NUM', default=1, + help='Number of lines of diff context to use ' + '[%(default)s]') + parser.add_argument('-d', '--debug', dest='debug', action='store_true', + help='Show debugging') + + options, args = parser.parse_known_args() + + if options.serve: + if options.log: + parser.error('--log does not make sense in webserver mode.') + if options.json: + parser.error('--json does not make sense in webserver mode.') + if options.recurse: + parser.error('--recurse does not make sense in webserver mode.') + if len(args) > 0: + parser.error('Specifying commit-ishs does not make sense in ' + 'webserver mode.') + else: + if len(args) == 0: + parser.error('You must specify at least one commit-ish.') + + return options, args + + +def cli(options, args): + detector = DependencyDetector(options) + + if options.json: + listener = JSONDependencyListener(options) + else: + listener = CLIDependencyListener(options) + + detector.add_listener(listener) + + for dependent_rev in args: + try: + detector.find_dependencies(dependent_rev) + except KeyboardInterrupt: + pass + + if options.json: + print(json.dumps(listener.json(), sort_keys=True, indent=4)) + + +def serve(options): + try: + import flask + from flask import Flask, send_file, safe_join + from flask.json import jsonify + except ImportError: + abort("Cannot find flask module which is required for webserver mode.") + + webserver = Flask('git-deps') + here = os.path.dirname(os.path.realpath(__file__)) + root = os.path.join(here, 'html') + webserver.root_path = root + + ########################################################## + # Static content + + @webserver.route('/') + def main_page(): + return send_file('git-deps.html') + + @webserver.route('/tip-template.html') + def tip_template(): + return send_file('tip-template.html') + + @webserver.route('/test.json') + def data(): + return send_file('test.json') + + def make_subdir_handler(subdir): + def subdir_handler(filename): + path = safe_join(root, subdir) + path = safe_join(path, filename) + if os.path.exists(path): + return send_file(path) + else: + flask.abort(404) + return subdir_handler + + for subdir in ('node_modules', 'css', 'js'): + fn = make_subdir_handler(subdir) + route = '/%s/<path:filename>' % subdir + webserver.add_url_rule(route, subdir + '_handler', fn) + + ########################################################## + # Dynamic content + + def json_error(status_code, error_class, message, **extra): + json = { + 'status': status_code, + 'error_class': error_class, + 'message': message, + } + json.update(extra) + response = jsonify(json) + response.status_code = status_code + return response + + @webserver.route('/options') + def send_options(): + client_options = options.__dict__ + client_options['repo_path'] = os.getcwd() + return jsonify(client_options) + + @webserver.route('/deps.json/<commitish>') + def deps(commitish): + detector = DependencyDetector(options) + listener = JSONDependencyListener(options) + detector.add_listener(listener) + + try: + root_commit = detector.get_commit(commitish) + except InvalidCommitish as e: + return json_error( + 422, 'Invalid commitish', + "Could not resolve commitish '%s'" % commitish, + commitish=commitish) + + detector.find_dependencies(commitish) + json = listener.json() + json['root'] = { + 'commitish': commitish, + 'sha1': root_commit.hex, + 'abbrev': GitUtils.abbreviate_sha1(root_commit.hex), + } + return jsonify(json) + + # We don't want to see double-decker warnings, so check + # WERKZEUG_RUN_MAIN which is only set for the first startup, not + # on app reloads. + if options.debug and not os.getenv('WERKZEUG_RUN_MAIN'): + print("!! WARNING! Debug mode enabled, so webserver is completely " + "insecure!") + print("!! Arbitrary code can be executed from browser!") + print() + webserver.run(port=options.port, debug=options.debug, host=options.bindaddr) + + +def main(): + options, args = parse_args() + # rev_list = sys.stdin.readlines() + + if options.serve: + serve(options) + else: + try: + cli(options, args) + except InvalidCommitish as e: + abort(e.message()) + + +if __name__ == "__main__": + main() |