stable-deps

Build a list of commit dependencies to apply a given commit.

Signed-off-by: Sasha Levin <sasha.levin@oracle.com>
diff --git a/README b/README
index 144760b..58b4ef6 100644
--- a/README
+++ b/README
@@ -104,3 +104,15 @@
 
  - 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()