aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Spiers <git@adamspiers.org>2016-06-11 22:20:04 +0100
committerAdam Spiers <git@adamspiers.org>2018-05-15 13:42:16 +0100
commit2c9d23b0291157eb1096384ff76e0122747b9bdf (patch)
tree524c7b479b65a478c998c28475d52e636b919200
parent9a741f07167dcb6cc81a8f87036d1ea75c4270d3 (diff)
downloadgit-deps-2c9d23b0291157eb1096384ff76e0122747b9bdf.tar.gz
convert into a proper Python module
Sem-Ver: api-break
-rw-r--r--.coveragerc23
-rw-r--r--.gitignore41
-rw-r--r--AUTHORS.rst5
-rw-r--r--CHANGES.rst9
-rw-r--r--INSTALL.md81
-rw-r--r--MANIFEST.in1
-rw-r--r--README.md44
-rw-r--r--docs/Makefile177
-rw-r--r--docs/_static/.gitignore1
-rw-r--r--docs/authors.rst2
-rw-r--r--docs/changes.rst2
-rw-r--r--docs/conf.py249
-rw-r--r--docs/index.rst45
-rw-r--r--docs/license.rst7
-rwxr-xr-xgit-deps.py848
-rw-r--r--git_deps/__init__.py6
-rwxr-xr-xgit_deps/cli.py148
-rw-r--r--git_deps/detector.py332
-rw-r--r--git_deps/errors.py6
-rw-r--r--git_deps/gitutils.py68
-rwxr-xr-xgit_deps/handler.py (renamed from gitfile-handler)12
-rw-r--r--git_deps/html/.gitignore (renamed from html/.gitignore)0
-rw-r--r--git_deps/html/css/animate.css (renamed from html/css/animate.css)0
-rw-r--r--git_deps/html/css/git-deps-tips.css (renamed from html/css/git-deps-tips.css)0
-rw-r--r--git_deps/html/css/git-deps.css (renamed from html/css/git-deps.css)0
-rw-r--r--git_deps/html/git-deps.html (renamed from html/git-deps.html)0
-rw-r--r--git_deps/html/js/.gitignore (renamed from html/js/.gitignore)0
-rw-r--r--git_deps/html/js/fullscreen.js (renamed from html/js/fullscreen.js)0
-rw-r--r--git_deps/html/js/git-deps-data.coffee (renamed from html/js/git-deps-data.coffee)0
-rw-r--r--git_deps/html/js/git-deps-graph.coffee (renamed from html/js/git-deps-graph.coffee)0
-rw-r--r--git_deps/html/js/git-deps-layout.coffee (renamed from html/js/git-deps-layout.coffee)0
-rw-r--r--git_deps/html/js/git-deps-noty.coffee (renamed from html/js/git-deps-noty.coffee)0
-rw-r--r--git_deps/html/package.json (renamed from html/package.json)0
-rw-r--r--git_deps/html/test.json (renamed from html/test.json)0
-rw-r--r--git_deps/html/tip-template.html (renamed from html/tip-template.html)0
-rw-r--r--git_deps/listener/__init__.py0
-rw-r--r--git_deps/listener/base.py35
-rw-r--r--git_deps/listener/cli.py56
-rw-r--r--git_deps/listener/json.py86
-rw-r--r--git_deps/server.py122
-rw-r--r--git_deps/utils.py8
-rw-r--r--images/youtube-porting-thumbnail.png (renamed from html/images/youtube-porting-thumbnail.png)bin173700 -> 173700 bytes
-rw-r--r--images/youtube-thumbnail.png (renamed from html/images/youtube-thumbnail.png)bin393767 -> 393767 bytes
-rw-r--r--requirements.txt2
-rw-r--r--setup.cfg71
-rw-r--r--setup.py23
-rw-r--r--share/gitfile-handler.desktop (renamed from gitfile-handler.desktop)0
-rw-r--r--test-requirements.txt5
-rw-r--r--tests/.gitignore (renamed from test/.gitignore)0
-rw-r--r--tests/conftest.py12
-rwxr-xr-xtests/create-repo.sh (renamed from test/create-repo.sh)0
-rw-r--r--tests/test_skeleton.py17
-rw-r--r--tox.ini25
53 files changed, 1677 insertions, 892 deletions
diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 0000000..b46c089
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,23 @@
+# .coveragerc to control coverage.py
+[run]
+branch = True
+source = git_deps
+# omit = bad_file.py
+
+[report]
+# Regexes for lines to exclude from consideration
+exclude_lines =
+ # Have to re-enable the standard pragma
+ pragma: no cover
+
+ # Don't complain about missing debug-only code:
+ def __repr__
+ if self\.debug
+
+ # Don't complain if tests don't hit defensive assertion code:
+ raise AssertionError
+ raise NotImplementedError
+
+ # Don't complain if non-runnable code isn't run:
+ if 0:
+ if __name__ == .__main__.:
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f41c0ad
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,41 @@
+# Temporary and binary files
+*~
+*.py[cod]
+*.so
+*.cfg
+!setup.cfg
+*.orig
+*.log
+*.pot
+__pycache__/*
+.cache/*
+.*.swp
+
+# Project files
+.ropeproject
+.project
+.pydevproject
+.settings
+.idea
+
+# Package files
+*.egg
+*.eggs/
+.installed.cfg
+*.egg-info
+
+# Unittest and coverage
+htmlcov/*
+.coverage
+.tox
+junit.xml
+coverage.xml
+
+# Build and docs folder/files
+build/*
+dist/*
+sdist/*
+docs/api/*
+docs/_build/*
+cover/*
+MANIFEST
diff --git a/AUTHORS.rst b/AUTHORS.rst
new file mode 100644
index 0000000..66a2fe6
--- /dev/null
+++ b/AUTHORS.rst
@@ -0,0 +1,5 @@
+==========
+Developers
+==========
+
+* Adam Spiers <git@adamspiers.org>
diff --git a/CHANGES.rst b/CHANGES.rst
new file mode 100644
index 0000000..380781a
--- /dev/null
+++ b/CHANGES.rst
@@ -0,0 +1,9 @@
+=========
+Changelog
+=========
+
+Version 0.2.0
+=============
+
+- Turned into a proper Python module, using PyScaffold.
+
diff --git a/INSTALL.md b/INSTALL.md
new file mode 100644
index 0000000..c69d4d1
--- /dev/null
+++ b/INSTALL.md
@@ -0,0 +1,81 @@
+Installation
+============
+
+`git-deps` requires [pygit2](http://www.pygit2.org/), which in return
+requires [libgit2](https://libgit2.github.com/). `git-deps` and
+pygit2 are both Python modules, but libgit2 is not. This means
+that there are a few ways to approach installation, detailed below.
+Corrections and additions to these instructions are very welcome!
+
+## Option 1: Install pygit2 and libgit2 from OS packages, and `git-deps` as a Python module
+
+if you are using Linux, there is a good chance that your distribution
+already offers packages for both pygit2 and libgit2, in which case
+installing pygit2 from packages should also automatically install
+libgit2. For example, on openSUSE, just do:
+
+ sudo zypper install python-pygit2
+
+or on Debian:
+
+ sudo apt-get install python-pygit2
+
+and then install `git-deps`:
+
+ pip install git-deps
+
+## Option 2: Install libgit2 from OS packages, and `git-deps` / pygit2 as Python modules
+
+In this case it should be enough to install libgit2 via your
+distribution's packaging tool, e.g. on openSUSE:
+
+ sudo zypper install libgit2-22
+
+Then install `git-deps` which should also automatically install pygit2:
+
+ pip install git-deps
+
+## Option 3: Install everything from source
+
+First follow
+[the installation instructions for pygit2](http://www.pygit2.org/install.html).
+
+Then clone this repository and follow the standard Python module
+installation route, e.g.
+
+ python setup.py install
+
+## Option 4: Installation via Docker
+
+Rather than following the above manual steps, you can try
+[an alternative approach created by Paul Wellner Bou which facilitates running `git-deps` in a Docker container](https://github.com/paulwellnerbou/git-deps-docker).
+This has been tested on Ubuntu 14.10, where it was used as a way to
+circumvent difficulties with installing libgit2 >= 0.22.
+
+## Check installation
+
+Now `git-deps` should be on your `$PATH`, which means that executing
+`git deps` (with a space, not a hyphen) should also work.
+
+## Install support for web-based graph visualization (`--serve` option)
+
+If you want to use the shiny new graph visualization web server
+functionality, you will need to install some additional dependencies:
+
+* As `root`, install the command line version of `browserify` with
+
+ npm install -g browserify
+* To install the required Javascript libraries, you will need
+ [`npm`](https://www.npmjs.com/) installed, and then type:
+
+ cd git_deps/html
+ npm install
+ browserify -t coffeeify -d js/git-deps-graph.coffee -o js/bundle.js
+
+ (If you are developing `git-deps` then replace `browserify` with
+ `watchify -v` in order to continually regenerate `bundle.js`
+ whenever any of the input files change.)
+* You will need the [Flask](http://flask.pocoo.org/) Python
+ module installed.
+
+Then `git deps --serve` should work.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..ff1d434
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1 @@
+graft git_deps/html \ No newline at end of file
diff --git a/README.md b/README.md
index a0bfbe1..65dce49 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ git-deps
between commits in a [git](http://git-scm.com/) repository. Here's
a screencast demonstration:
-[![YouTube screencast](./html/images/youtube-thumbnail.png)](http://youtu.be/irQ5gMMz-gE)
+[![YouTube screencast](./images/youtube-thumbnail.png)](http://youtu.be/irQ5gMMz-gE)
I also spoke about the tool in
[episode #32 of the GitMinutes podcast](http://episodes.gitminutes.com/2015/03/gitminutes-32-adam-spiers-on-git-deps.html).
@@ -55,7 +55,7 @@ the minimum number of other dependent commits which would also need to
be cherry-picked to provide the context for commit "A" to cleanly
apply. Here's a quick demo!
-[![YouTube porting screencast](./html/images/youtube-porting-thumbnail.png)](http://youtu.be/DVksJMXxVIM)
+[![YouTube porting screencast](./images/youtube-porting-thumbnail.png)](http://youtu.be/DVksJMXxVIM)
### Use case 2: splitting a patch series
@@ -107,47 +107,11 @@ programmatically predict whether operations such as merge / rebase /
cherry-pick would succeed, but actually it's probably cheaper and more
reliable simply to perform the operation and then roll back.
+
Installation
------------
-### Manual installation
-
-`git-deps` requires [Pygit2](http://www.pygit2.org/), so first
-[install that](http://www.pygit2.org/install.html). If you are using
-Linux, there is a good chance that your distribution already offers
-packages for it. For example, on openSUSE, just do:
-
- sudo zypper install python-pygit2
-
-Then just symlink `git-deps` so it's anywhere on your `$PATH`, e.g.
-
- ln -s /path/to/git-deps/repo/git-deps.py ~/bin/git-deps
-
-If you want to use the shiny new graph visualization web server
-functionality, you will need to install some dependencies:
-
-* As `root`, install the command line version of `browserify` with
-
- npm install -g browserify
-* To install the required Javascript libraries, you will need
- [`npm`](https://www.npmjs.com/) installed, and then type:
-
- cd html
- npm install
- browserify -t coffeeify -d js/git-deps-graph.coffee -o js/bundle.js
-
- (If you are developing `git-deps` then replace `browserify` with
- `watchify -v` in order to continually regenerate `bundle.js`
- whenever any of the input files change.)
-* You will need the [Flask](http://flask.pocoo.org/) Python
- module installed.
-
-### Installation via Docker
-
-Rather than following the above manual steps, you can try
-[an alternative approach created by Paul Wellner Bou which facilitates running `git-deps` in a Docker container](https://github.com/paulwellnerbou/git-deps-docker).
-This has been tested on Ubuntu 14.10, where it was used as a way to
-circumvent difficulties with installing `libgit2` >= 0.22.
+Please see [the `INSTALL.md` file](INSTALL.md).
Usage
-----
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..eb0e9c2
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,177 @@
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS =
+SPHINXBUILD = sphinx-build
+PAPER =
+BUILDDIR = _build
+
+# User-friendly check for sphinx-build
+ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $?), 1)
+$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
+endif
+
+# Internal variables.
+PAPEROPT_a4 = -D latex_paper_size=a4
+PAPEROPT_letter = -D latex_paper_size=letter
+ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+# the i18n builder cannot share the environment and doctrees with the others
+I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+
+.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
+
+help:
+ @echo "Please use \`make <target>' where <target> is one of"
+ @echo " html to make standalone HTML files"
+ @echo " dirhtml to make HTML files named index.html in directories"
+ @echo " singlehtml to make a single large HTML file"
+ @echo " pickle to make pickle files"
+ @echo " json to make JSON files"
+ @echo " htmlhelp to make HTML files and a HTML help project"
+ @echo " qthelp to make HTML files and a qthelp project"
+ @echo " devhelp to make HTML files and a Devhelp project"
+ @echo " epub to make an epub"
+ @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
+ @echo " latexpdf to make LaTeX files and run them through pdflatex"
+ @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
+ @echo " text to make text files"
+ @echo " man to make manual pages"
+ @echo " texinfo to make Texinfo files"
+ @echo " info to make Texinfo files and run them through makeinfo"
+ @echo " gettext to make PO message catalogs"
+ @echo " changes to make an overview of all changed/added/deprecated items"
+ @echo " xml to make Docutils-native XML files"
+ @echo " pseudoxml to make pseudoxml-XML files for display purposes"
+ @echo " linkcheck to check all external links for integrity"
+ @echo " doctest to run all doctests embedded in the documentation (if enabled)"
+
+clean:
+ rm -rf $(BUILDDIR)/*
+
+html:
+ $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+
+dirhtml:
+ $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
+
+singlehtml:
+ $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
+ @echo
+ @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
+
+pickle:
+ $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
+ @echo
+ @echo "Build finished; now you can process the pickle files."
+
+json:
+ $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
+ @echo
+ @echo "Build finished; now you can process the JSON files."
+
+htmlhelp:
+ $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
+ @echo
+ @echo "Build finished; now you can run HTML Help Workshop with the" \
+ ".hhp project file in $(BUILDDIR)/htmlhelp."
+
+qthelp:
+ $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
+ @echo
+ @echo "Build finished; now you can run "qcollectiongenerator" with the" \
+ ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
+ @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/git-deps.qhcp"
+ @echo "To view the help file:"
+ @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/git-deps.qhc"
+
+devhelp:
+ $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
+ @echo
+ @echo "Build finished."
+ @echo "To view the help file:"
+ @echo "# mkdir -p $HOME/.local/share/devhelp/git-deps"
+ @echo "# ln -s $(BUILDDIR)/devhelp $HOME/.local/share/devhelp/git-deps"
+ @echo "# devhelp"
+
+epub:
+ $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
+ @echo
+ @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
+
+latex:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo
+ @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
+ @echo "Run \`make' in that directory to run these through (pdf)latex" \
+ "(use \`make latexpdf' here to do that automatically)."
+
+latexpdf:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo "Running LaTeX files through pdflatex..."
+ $(MAKE) -C $(BUILDDIR)/latex all-pdf
+ @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+latexpdfja:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo "Running LaTeX files through platex and dvipdfmx..."
+ $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
+ @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+text:
+ $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
+ @echo
+ @echo "Build finished. The text files are in $(BUILDDIR)/text."
+
+man:
+ $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
+ @echo
+ @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
+
+texinfo:
+ $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+ @echo
+ @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
+ @echo "Run \`make' in that directory to run these through makeinfo" \
+ "(use \`make info' here to do that automatically)."
+
+info:
+ $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+ @echo "Running Texinfo files through makeinfo..."
+ make -C $(BUILDDIR)/texinfo info
+ @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
+
+gettext:
+ $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
+ @echo
+ @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
+
+changes:
+ $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
+ @echo
+ @echo "The overview file is in $(BUILDDIR)/changes."
+
+linkcheck:
+ $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
+ @echo
+ @echo "Link check complete; look for any errors in the above output " \
+ "or in $(BUILDDIR)/linkcheck/output.txt."
+
+doctest:
+ $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
+ @echo "Testing of doctests in the sources finished, look at the " \
+ "results in $(BUILDDIR)/doctest/output.txt."
+
+xml:
+ $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
+ @echo
+ @echo "Build finished. The XML files are in $(BUILDDIR)/xml."
+
+pseudoxml:
+ $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
+ @echo
+ @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
diff --git a/docs/_static/.gitignore b/docs/_static/.gitignore
new file mode 100644
index 0000000..3c96363
--- /dev/null
+++ b/docs/_static/.gitignore
@@ -0,0 +1 @@
+# Empty directory
diff --git a/docs/authors.rst b/docs/authors.rst
new file mode 100644
index 0000000..cd8e091
--- /dev/null
+++ b/docs/authors.rst
@@ -0,0 +1,2 @@
+.. _authors:
+.. include:: ../AUTHORS.rst
diff --git a/docs/changes.rst b/docs/changes.rst
new file mode 100644
index 0000000..257630a
--- /dev/null
+++ b/docs/changes.rst
@@ -0,0 +1,2 @@
+.. _changes:
+.. include:: ../CHANGES.rst
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..51f2a4a
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,249 @@
+# -*- coding: utf-8 -*-
+#
+# This file is execfile()d with the current directory set to its containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+import sys
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+# sys.path.insert(0, os.path.abspath('.'))
+
+# -- Hack for ReadTheDocs ------------------------------------------------------
+# This hack is necessary since RTD does not issue `sphinx-apidoc` before running
+# `sphinx-build -b html . _build/html`. See Issue:
+# https://github.com/rtfd/readthedocs.org/issues/1139
+# DON'T FORGET: Check the box "Install your project inside a virtualenv using
+# setup.py install" in the RTD Advanced Settings.
+import os
+on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
+if on_rtd:
+ import inspect
+ from sphinx import apidoc
+
+ __location__ = os.path.join(os.getcwd(), os.path.dirname(
+ inspect.getfile(inspect.currentframe())))
+
+ output_dir = os.path.join(__location__, "../docs/api")
+ module_dir = os.path.join(__location__, "../git_deps")
+ cmd_line_template = "sphinx-apidoc -f -o {outputdir} {moduledir}"
+ cmd_line = cmd_line_template.format(outputdir=output_dir, moduledir=module_dir)
+ apidoc.main(cmd_line.split(" "))
+
+# -- General configuration -----------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+# needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be extensions
+# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
+extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.todo',
+ 'sphinx.ext.autosummary', 'sphinx.ext.viewcode', 'sphinx.ext.coverage',
+ 'sphinx.ext.doctest', 'sphinx.ext.ifconfig', 'sphinx.ext.pngmath',
+ 'sphinx.ext.napoleon']
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix of source filenames.
+source_suffix = '.rst'
+
+# The encoding of source files.
+# source_encoding = 'utf-8-sig'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = u'git-deps'
+copyright = u'2016, Adam Spiers'
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+#
+# The short X.Y version.
+version = '' # Is set by calling `setup.py docs`
+# The full version, including alpha/beta/rc tags.
+release = '' # Is set by calling `setup.py docs`
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+# language = None
+
+# There are two options for replacing |today|: either, you set today to some
+# non-false value, then it is used:
+# today = ''
+# Else, today_fmt is used as the format for a strftime call.
+# today_fmt = '%B %d, %Y'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+exclude_patterns = ['_build']
+
+# The reST default role (used for this markup: `text`) to use for all documents.
+# default_role = None
+
+# If true, '()' will be appended to :func: etc. cross-reference text.
+# add_function_parentheses = True
+
+# If true, the current module name will be prepended to all description
+# unit titles (such as .. function::).
+# add_module_names = True
+
+# If true, sectionauthor and moduleauthor directives will be shown in the
+# output. They are ignored by default.
+# show_authors = False
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+# A list of ignored prefixes for module index sorting.
+# modindex_common_prefix = []
+
+# If true, keep warnings as "system message" paragraphs in the built documents.
+# keep_warnings = False
+
+
+# -- Options for HTML output ---------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages. See the documentation for
+# a list of builtin themes.
+html_theme = 'alabaster'
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further. For a list of options available for each theme, see the
+# documentation.
+# html_theme_options = {}
+
+# Add any paths that contain custom themes here, relative to this directory.
+# html_theme_path = []
+
+# The name for this set of Sphinx documents. If None, it defaults to
+# "<project> v<release> documentation".
+try:
+ from git_deps import __version__ as version
+except ImportError:
+ pass
+else:
+ release = version
+
+# A shorter title for the navigation bar. Default is the same as html_title.
+# html_short_title = None
+
+# The name of an image file (relative to this directory) to place at the top
+# of the sidebar.
+# html_logo = ""
+
+# The name of an image file (within the static path) to use as favicon of the
+# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
+# pixels large.
+# html_favicon = None
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
+# using the given strftime format.
+# html_last_updated_fmt = '%b %d, %Y'
+
+# If true, SmartyPants will be used to convert quotes and dashes to
+# typographically correct entities.
+# html_use_smartypants = True
+
+# Custom sidebar templates, maps document names to template names.
+# html_sidebars = {}
+
+# Additional templates that should be rendered to pages, maps page names to
+# template names.
+# html_additional_pages = {}
+
+# If false, no module index is generated.
+# html_domain_indices = True
+
+# If false, no index is generated.
+# html_use_index = True
+
+# If true, the index is split into individual pages for each letter.
+# html_split_index = False
+
+# If true, links to the reST sources are added to the pages.
+# html_show_sourcelink = True
+
+# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
+# html_show_sphinx = True
+
+# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
+# html_show_copyright = True
+
+# If true, an OpenSearch description file will be output, and all pages will
+# contain a <link> tag referring to it. The value of this option must be the
+# base URL from which the finished HTML is served.
+# html_use_opensearch = ''
+
+# This is the file name suffix for HTML files (e.g. ".xhtml").
+# html_file_suffix = None
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'git_deps-doc'
+
+
+# -- Options for LaTeX output --------------------------------------------------
+
+latex_elements = {
+# The paper size ('letterpaper' or 'a4paper').
+# 'papersize': 'letterpaper',
+
+# The font size ('10pt', '11pt' or '12pt').
+# 'pointsize': '10pt',
+
+# Additional stuff for the LaTeX preamble.
+# 'preamble': '',
+}
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title, author, documentclass [howto/manual]).
+latex_documents = [
+ ('index', 'user_guide.tex', u'git-deps Documentation',
+ u'Adam Spiers', 'manual'),
+]
+
+# The name of an image file (relative to this directory) to place at the top of
+# the title page.
+# latex_logo = ""
+
+# For "manual" documents, if this is true, then toplevel headings are parts,
+# not chapters.
+# latex_use_parts = False
+
+# If true, show page references after internal links.
+# latex_show_pagerefs = False
+
+# If true, show URL addresses after external links.
+# latex_show_urls = False
+
+# Documents to append as an appendix to all manuals.
+# latex_appendices = []
+
+# If false, no module index is generated.
+# latex_domain_indices = True
+
+# -- External mapping ------------------------------------------------------------
+python_version = '.'.join(map(str, sys.version_info[0:2]))
+intersphinx_mapping = {
+ 'sphinx': ('http://sphinx.pocoo.org', None),
+ 'python': ('http://docs.python.org/' + python_version, None),
+ 'matplotlib': ('http://matplotlib.sourceforge.net', None),
+ 'numpy': ('http://docs.scipy.org/doc/numpy', None),
+ 'sklearn': ('http://scikit-learn.org/stable', None),
+ 'pandas': ('http://pandas.pydata.org/pandas-docs/stable', None),
+ 'scipy': ('http://docs.scipy.org/doc/scipy/reference/', None),
+}
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..be83b1b
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,45 @@
+========
+git-deps
+========
+
+This is the documentation of **git-deps**.
+
+.. note::
+
+ This is the main page of your project's `Sphinx <http://sphinx-doc.org/>`_
+ documentation. It is formatted in `reStructuredText
+ <http://sphinx-doc.org/rest.html>`__. Add additional pages by creating
+ rst-files in ``docs`` and adding them to the `toctree
+ <http://sphinx-doc.org/markup/toctree.html>`_ below. Use then
+ `references <http://sphinx-doc.org/markup/inline.html>`__ in order to link
+ them from this page, e.g. :ref:`authors <authors>` and :ref:`changes`.
+ It is also possible to refer to the documentation of other Python packages
+ with the `Python domain syntax
+ <http://sphinx-doc.org/domains.html#the-python-domain>`__. By default you
+ can reference the documentation of `Sphinx <http://sphinx.pocoo.org>`__,
+ `Python <http://docs.python.org/>`__, `matplotlib
+ <http://matplotlib.sourceforge.net>`__, `NumPy
+ <http://docs.scipy.org/doc/numpy>`__, `Scikit-Learn
+ <http://scikit-learn.org/stable>`__, `Pandas
+ <http://pandas.pydata.org/pandas-docs/stable>`__, `SciPy
+ <http://docs.scipy.org/doc/scipy/reference/>`__. You can add more by
+ extending the ``intersphinx_mapping`` in your Sphinx's ``conf.py``.
+
+Contents
+========
+
+.. toctree::
+ :maxdepth: 2
+
+ License <license>
+ Authors <authors>
+ Changelog <changes>
+ Module Reference <api/modules>
+
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/docs/license.rst b/docs/license.rst
new file mode 100644
index 0000000..6437528
--- /dev/null
+++ b/docs/license.rst
@@ -0,0 +1,7 @@
+.. _license:
+
+=======
+License
+=======
+
+.. literalinclude:: ../LICENSE.txt
diff --git a/git-deps.py b/git-deps.py
deleted file mode 100755
index 216bb9e..0000000
--- a/git-deps.py
+++ /dev/null
@@ -1,848 +0,0 @@
-#!/usr/bin/env python2
-#
-# 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"
- elif distro == 'debian':
- install_guide = \
- "You should be able to install it with something like:\n\n" \
- " sudo apt-get 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 __init__(self, options):
- super(CLIDependencyListener, self).__init__(options)
-
- # Count each mention of each revision, so we can avoid duplicating
- # commits in the output.
- self._revs = {}
-
- def new_commit(self, commit):
- rev = commit.hex
- if rev not in self._revs:
- self._revs[rev] = 0
- self._revs[rev] += 1
-
- def new_dependency(self, dependent, dependency, path, line_num):
- dependent_sha1 = dependent.hex
- dependency_sha1 = dependency.hex
-
- if self.options.multi:
- if self.options.log:
- print("%s depends on:" % dependent_sha1)
- else:
- print("%s %s" % (dependent_sha1, dependency_sha1))
- else:
- if not self.options.log and self._revs[dependency_sha1] <= 1:
- print(dependency_sha1)
-
- if self.options.log and self._revs[dependency_sha1] <= 1:
- 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
-
- @classmethod
- def rev_list(cls, rev_range):
- cmd = ['git', 'rev-list', rev_range]
- return subprocess.check_output(cmd).strip().split('\n')
-
-
-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 seen_commit(self, rev):
- return rev in self.commits
-
- 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.delta.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 line in hunk.lines:
- if "\n\\ No newline at end of file" == line.content.rstrip():
- break
- if line.origin == '+':
- rev = ln = ''
- else:
- rev = line_to_culprit[line_num]
- ln = line_num
- line_num += 1
- self.logger.debug(diff_format % (rev, ln, line.origin, line.content.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()
-
- # Are we potentially detecting dependencies for more than one commit?
- # Even if we're not recursing, the user could specify multiple commits
- # via CLI arguments.
- options.multi = options.recurse
-
- 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)
-
- if len(args) > 1:
- options.multi = True
-
- for revspec in args:
- revs = GitUtils.rev_list(revspec)
- if len(revs) > 1:
- options.multi = True
-
- for rev in revs:
- try:
- detector.find_dependencies(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/<revspec>')
- def deps(revspec):
- detector = DependencyDetector(options)
- listener = JSONDependencyListener(options)
- detector.add_listener(listener)
-
- if '..' in revspec:
- try:
- revisions = GitUtils.rev_list(revspec)
- except subprocess.CalledProcessError as e:
- return json_err(
- 422, 'Invalid revision range',
- "Could not resolve revision range '%s'" % revspec,
- revspec=revspec)
- else:
- revisions = [revspec]
-
- for rev in revisions:
- try:
- commit = detector.get_commit(rev)
- except InvalidCommitish as e:
- return json_error(
- 422, 'Invalid revision',
- "Could not resolve revision '%s'" % rev,
- rev=rev)
-
- detector.find_dependencies(rev)
-
- tip_commit = detector.get_commit(revisions[0])
- tip_sha1 = tip_commit.hex
-
- json = listener.json()
- json['query'] = {
- 'revspec': revspec,
- 'revisions': revisions,
- 'tip_sha1': tip_sha1,
- 'tip_abbrev': GitUtils.abbreviate_sha1(tip_sha1),
- }
- 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()
diff --git a/git_deps/__init__.py b/git_deps/__init__.py
new file mode 100644
index 0000000..896994c
--- /dev/null
+++ b/git_deps/__init__.py
@@ -0,0 +1,6 @@
+import pkg_resources
+
+try:
+ __version__ = pkg_resources.get_distribution(__name__).version
+except:
+ __version__ = 'unknown'
diff --git a/git_deps/cli.py b/git_deps/cli.py
new file mode 100755
index 0000000..0fa06d1
--- /dev/null
+++ b/git_deps/cli.py
@@ -0,0 +1,148 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+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 sys
+
+from git_deps import __version__
+from git_deps.detector import DependencyDetector
+from git_deps.errors import InvalidCommitish
+from git_deps.gitutils import GitUtils
+from git_deps.listener.json import JSONDependencyListener
+from git_deps.listener.cli import CLIDependencyListener
+from git_deps.server import serve
+from git_deps.utils import abort
+
+__author__ = "Adam Spiers"
+__copyright__ = "Adam Spiers"
+__license__ = "GPL-2+"
+
+
+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('-v', '--version', action='version',
+ version='git-deps {ver}'.format(ver=__version__))
+ 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()
+
+ # Are we potentially detecting dependencies for more than one commit?
+ # Even if we're not recursing, the user could specify multiple commits
+ # via CLI arguments.
+ options.multi = options.recurse
+
+ 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)
+
+ if len(args) > 1:
+ options.multi = True
+
+ for revspec in args:
+ revs = GitUtils.rev_list(revspec)
+ if len(revs) > 1:
+ options.multi = True
+
+ for rev in revs:
+ try:
+ detector.find_dependencies(rev)
+ except KeyboardInterrupt:
+ pass
+
+ if options.json:
+ print(json.dumps(listener.json(), sort_keys=True, indent=4))
+
+
+def main(args):
+ 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())
+
+
+def run():
+ main(sys.argv[1:])
+
+
+if __name__ == "__main__":
+ run()
diff --git a/git_deps/detector.py b/git_deps/detector.py
new file mode 100644
index 0000000..650c077
--- /dev/null
+++ b/git_deps/detector.py
@@ -0,0 +1,332 @@
+import logging
+import re
+import subprocess
+import sys
+
+import pygit2
+
+from git_deps.utils import abort
+from git_deps.listener.base import DependencyListener
+from git_deps.errors import InvalidCommitish
+
+
+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 seen_commit(self, rev):
+ return rev in self.commits
+
+ 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.delta.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 line in hunk.lines:
+ if "\n\\ No newline at end of file" == line.content.rstrip():
+ break
+ if line.origin == '+':
+ rev = ln = ''
+ else:
+ rev = line_to_culprit[line_num]
+ ln = line_num
+ line_num += 1
+ self.logger.debug(diff_format % (rev, ln, line.origin, line.content.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()
+ ]
diff --git a/git_deps/errors.py b/git_deps/errors.py
new file mode 100644
index 0000000..1074624
--- /dev/null
+++ b/git_deps/errors.py
@@ -0,0 +1,6 @@
+class InvalidCommitish(StandardError):
+ def __init__(self, commitish):
+ self.commitish = commitish
+
+ def message(self):
+ return "Couldn't resolve commitish %s" % self.commitish
diff --git a/git_deps/gitutils.py b/git_deps/gitutils.py
new file mode 100644
index 0000000..f5d8281
--- /dev/null
+++ b/git_deps/gitutils.py
@@ -0,0 +1,68 @@
+import re
+import subprocess
+
+
+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
+
+ @classmethod
+ def rev_list(cls, rev_range):
+ cmd = ['git', 'rev-list', rev_range]
+ return subprocess.check_output(cmd).strip().split('\n')
diff --git a/gitfile-handler b/git_deps/handler.py
index 5fc5d3e..2fe71ad 100755
--- a/gitfile-handler
+++ b/git_deps/handler.py
@@ -15,11 +15,11 @@ def abort(msg, exitcode=1):
def usage():
abort("usage: git-handler URL")
-def main():
- if len(sys.argv) != 2:
+def main(args):
+ if len(args) != 1:
usage()
- url = urlparse(sys.argv[1])
+ url = args[0]
if url.scheme != 'gitfile':
abort("URL must use gitfile:// scheme")
@@ -30,5 +30,9 @@ def main():
subprocess.Popen(['gitk', '--all', '--select-commit=%s' % rev])
+def run():
+ main(sys.argv[1:])
+
+
if __name__ == "__main__":
- main()
+ run()
diff --git a/html/.gitignore b/git_deps/html/.gitignore
index 68b9e27..68b9e27 100644
--- a/html/.gitignore
+++ b/git_deps/html/.gitignore
diff --git a/html/css/animate.css b/git_deps/html/css/animate.css
index f784ce8..f784ce8 100644
--- a/html/css/animate.css
+++ b/git_deps/html/css/animate.css
diff --git a/html/css/git-deps-tips.css b/git_deps/html/css/git-deps-tips.css
index 909badb..909badb 100644
--- a/html/css/git-deps-tips.css
+++ b/git_deps/html/css/git-deps-tips.css
diff --git a/html/css/git-deps.css b/git_deps/html/css/git-deps.css
index ea21821..ea21821 100644
--- a/html/css/git-deps.css
+++ b/git_deps/html/css/git-deps.css
diff --git a/html/git-deps.html b/git_deps/html/git-deps.html
index 6ced12b..6ced12b 100644
--- a/html/git-deps.html
+++ b/git_deps/html/git-deps.html
diff --git a/html/js/.gitignore b/git_deps/html/js/.gitignore
index 0e804e3..0e804e3 100644
--- a/html/js/.gitignore
+++ b/git_deps/html/js/.gitignore
diff --git a/html/js/fullscreen.js b/git_deps/html/js/fullscreen.js
index 6d8f3d8..6d8f3d8 100644
--- a/html/js/fullscreen.js
+++ b/git_deps/html/js/fullscreen.js
diff --git a/html/js/git-deps-data.coffee b/git_deps/html/js/git-deps-data.coffee
index 34715a3..34715a3 100644
--- a/html/js/git-deps-data.coffee
+++ b/git_deps/html/js/git-deps-data.coffee
diff --git a/html/js/git-deps-graph.coffee b/git_deps/html/js/git-deps-graph.coffee
index 7ad6827..7ad6827 100644
--- a/html/js/git-deps-graph.coffee
+++ b/git_deps/html/js/git-deps-graph.coffee
diff --git a/html/js/git-deps-layout.coffee b/git_deps/html/js/git-deps-layout.coffee
index 8b8cd05..8b8cd05 100644
--- a/html/js/git-deps-layout.coffee
+++ b/git_deps/html/js/git-deps-layout.coffee
diff --git a/html/js/git-deps-noty.coffee b/git_deps/html/js/git-deps-noty.coffee
index cec2b08..cec2b08 100644
--- a/html/js/git-deps-noty.coffee
+++ b/git_deps/html/js/git-deps-noty.coffee
diff --git a/html/package.json b/git_deps/html/package.json
index d4dfcd7..d4dfcd7 100644
--- a/html/package.json
+++ b/git_deps/html/package.json
diff --git a/html/test.json b/git_deps/html/test.json
index 3d73289..3d73289 100644
--- a/html/test.json
+++ b/git_deps/html/test.json
diff --git a/html/tip-template.html b/git_deps/html/tip-template.html
index 1362574..1362574 100644
--- a/html/tip-template.html
+++ b/git_deps/html/tip-template.html
diff --git a/git_deps/listener/__init__.py b/git_deps/listener/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/git_deps/listener/__init__.py
diff --git a/git_deps/listener/base.py b/git_deps/listener/base.py
new file mode 100644
index 0000000..594f438
--- /dev/null
+++ b/git_deps/listener/base.py
@@ -0,0 +1,35 @@
+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
diff --git a/git_deps/listener/cli.py b/git_deps/listener/cli.py
new file mode 100644
index 0000000..285c622
--- /dev/null
+++ b/git_deps/listener/cli.py
@@ -0,0 +1,56 @@
+import subprocess
+
+from git_deps.listener.base import DependencyListener
+
+
+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 __init__(self, options):
+ super(CLIDependencyListener, self).__init__(options)
+
+ # Count each mention of each revision, so we can avoid duplicating
+ # commits in the output.
+ self._revs = {}
+
+ def new_commit(self, commit):
+ rev = commit.hex
+ if rev not in self._revs:
+ self._revs[rev] = 0
+ self._revs[rev] += 1
+
+ def new_dependency(self, dependent, dependency, path, line_num):
+ dependent_sha1 = dependent.hex
+ dependency_sha1 = dependency.hex
+
+ if self.options.multi:
+ if self.options.log:
+ print("%s depends on:" % dependent_sha1)
+ else:
+ print("%s %s" % (dependent_sha1, dependency_sha1))
+ else:
+ if not self.options.log and self._revs[dependency_sha1] <= 1:
+ print(dependency_sha1)
+
+ if self.options.log and self._revs[dependency_sha1] <= 1:
+ 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)))
diff --git a/git_deps/listener/json.py b/git_deps/listener/json.py
new file mode 100644
index 0000000..aedb6fa
--- /dev/null
+++ b/git_deps/listener/json.py
@@ -0,0 +1,86 @@
+from git_deps.listener.base import DependencyListener
+
+from git_deps.gitutils import GitUtils
+
+
+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
diff --git a/git_deps/server.py b/git_deps/server.py
new file mode 100644
index 0000000..e996a33
--- /dev/null
+++ b/git_deps/server.py
@@ -0,0 +1,122 @@
+import os
+import subprocess
+
+from gitutils import GitUtils
+from git_deps.detector import DependencyDetector
+from git_deps.errors import InvalidCommitish
+from git_deps.listener.json import JSONDependencyListener
+from git_deps.utils import abort
+
+
+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/<revspec>')
+ def deps(revspec):
+ detector = DependencyDetector(options)
+ listener = JSONDependencyListener(options)
+ detector.add_listener(listener)
+
+ if '..' in revspec:
+ try:
+ revisions = GitUtils.rev_list(revspec)
+ except subprocess.CalledProcessError as e:
+ return json_err(
+ 422, 'Invalid revision range',
+ "Could not resolve revision range '%s'" % revspec,
+ revspec=revspec)
+ else:
+ revisions = [revspec]
+
+ for rev in revisions:
+ try:
+ commit = detector.get_commit(rev)
+ except InvalidCommitish as e:
+ return json_error(
+ 422, 'Invalid revision',
+ "Could not resolve revision '%s'" % rev,
+ rev=rev)
+
+ detector.find_dependencies(rev)
+
+ tip_commit = detector.get_commit(revisions[0])
+ tip_sha1 = tip_commit.hex
+
+ json = listener.json()
+ json['query'] = {
+ 'revspec': revspec,
+ 'revisions': revisions,
+ 'tip_sha1': tip_sha1,
+ 'tip_abbrev': GitUtils.abbreviate_sha1(tip_sha1),
+ }
+ 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)
diff --git a/git_deps/utils.py b/git_deps/utils.py
new file mode 100644
index 0000000..3661c00
--- /dev/null
+++ b/git_deps/utils.py
@@ -0,0 +1,8 @@
+from __future__ import print_function
+
+import sys
+
+
+def abort(msg, exitcode=1):
+ print(msg, file=sys.stderr)
+ sys.exit(exitcode)
diff --git a/html/images/youtube-porting-thumbnail.png b/images/youtube-porting-thumbnail.png
index e4de0c8..e4de0c8 100644
--- a/html/images/youtube-porting-thumbnail.png
+++ b/images/youtube-porting-thumbnail.png
Binary files differ
diff --git a/html/images/youtube-thumbnail.png b/images/youtube-thumbnail.png
index 25b0894..25b0894 100644
--- a/html/images/youtube-thumbnail.png
+++ b/images/youtube-thumbnail.png
Binary files differ
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..dfa9b99
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+pygit2>=0.22.1
+flask
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..9aef582
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,71 @@
+[metadata]
+name = git-deps
+summary = automatically detect dependencies between git commits
+author = Adam Spiers
+author-email = git@adamspiers.org
+license = GPL-2+
+home-page = https://github.com/aspiers/git-deps
+description-file = README.md
+classifier =
+ Development Status :: 4 - Beta
+ Environment :: Console
+ Environment :: Web Environment
+ Framework :: Flask
+ Intended Audience :: Developers
+ License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)
+ Natural Language :: English
+ Operating System :: OS Independent
+ Programming Language :: Python
+ Topic :: Software Development :: Version Control
+ Topic :: Utilities
+
+[entry_points]
+console_scripts =
+ git-deps = git_deps.cli:run
+ gitfile-handler = git_deps.handler:run
+
+[files]
+packages =
+ git_deps
+data_files =
+ share/git_deps = share/gitfile-handler.desktop
+
+[test]
+# py.test options when running `python setup.py test`
+addopts = tests
+
+[pytest]
+# Options for py.test:
+# Specify command line options as you would do when invoking py.test directly.
+# e.g. --cov-report html (or xml) for html/xml output or --junitxml junit.xml
+# in order to write a coverage file that can be read by Jenkins.
+addopts =
+ --cov git_deps --cov-report term-missing
+ --verbose
+
+[aliases]
+docs = build_sphinx
+
+[bdist_wheel]
+# Use this option if your package is pure-python
+universal = 1
+
+[build_sphinx]
+source_dir = docs
+build_dir = docs/_build
+
+[pbr]
+# Let pbr run sphinx-apidoc
+autodoc_tree_index_modules = True
+# autodoc_tree_excludes = ...
+# Let pbr itself generate the apidoc
+# autodoc_index_modules = True
+# autodoc_exclude_modules = ...
+# Convert warnings to errors
+# warnerrors = True
+
+[devpi:upload]
+# Options for the devpi: PyPI server and packaging tool
+# VCS export must be deactivated since we are using setuptools-scm
+no-vcs = 1
+formats = bdist_wheel
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..535b255
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,23 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+ Setup file for git_deps.
+
+ This file was generated with PyScaffold 2.5.6, a tool that easily
+ puts up a scaffold for your new Python project. Learn more under:
+ http://pyscaffold.readthedocs.org/
+"""
+
+import sys
+from setuptools import setup
+
+
+def setup_package():
+ needs_sphinx = {'build_sphinx', 'upload_docs'}.intersection(sys.argv)
+ sphinx = ['sphinx'] if needs_sphinx else []
+ setup(setup_requires=['six', 'pyscaffold>=2.5a0,<2.6a0'] + sphinx,
+ use_pyscaffold=True)
+
+
+if __name__ == "__main__":
+ setup_package()
diff --git a/gitfile-handler.desktop b/share/gitfile-handler.desktop
index dfef2d5..dfef2d5 100644
--- a/gitfile-handler.desktop
+++ b/share/gitfile-handler.desktop
diff --git a/test-requirements.txt b/test-requirements.txt
new file mode 100644
index 0000000..468f195
--- /dev/null
+++ b/test-requirements.txt
@@ -0,0 +1,5 @@
+# Add requirements only needed for your unittests and during development here.
+# They will be installed automatically when running `python setup.py test`.
+# ATTENTION: Don't remove pytest-cov and pytest as they are needed.
+pytest-cov
+pytest
diff --git a/test/.gitignore b/tests/.gitignore
index 0e79501..0e79501 100644
--- a/test/.gitignore
+++ b/tests/.gitignore
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..a2dac00
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,12 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+ Dummy conftest.py for git_deps.
+
+ If you don't know what this is for, just leave it empty.
+ Read more about conftest.py under:
+ https://pytest.org/latest/plugins.html
+"""
+from __future__ import print_function, absolute_import, division
+
+import pytest
diff --git a/test/create-repo.sh b/tests/create-repo.sh
index bfaffe2..bfaffe2 100755
--- a/test/create-repo.sh
+++ b/tests/create-repo.sh
diff --git a/tests/test_skeleton.py b/tests/test_skeleton.py
new file mode 100644
index 0000000..ec2ef23
--- /dev/null
+++ b/tests/test_skeleton.py
@@ -0,0 +1,17 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+import pytest
+from git_deps.skeleton import fib
+
+__author__ = "Adam Spiers"
+__copyright__ = "Adam Spiers"
+__license__ = "none"
+
+
+def test_fib():
+ assert fib(1) == 1
+ assert fib(2) == 1
+ assert fib(7) == 13
+ with pytest.raises(AssertionError):
+ fib(-10)
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..b2cf450
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,25 @@
+# Tox configuration file
+# Read more under https://tox.readthedocs.org/
+# THIS SCRIPT IS SUPPOSED TO BE AN EXAMPLE. MODIFY IT ACCORDING TO YOUR NEEDS!
+
+[tox]
+minversion = 1.8
+envlist = py27,py33,py34,flake8
+skip_missing_interpreters = True
+
+[testenv]
+changedir = tests
+commands =
+ py.test {posargs}
+deps =
+ pytest
+ -r{toxinidir}/requirements.txt
+
+[testenv:flake8]
+changedir = {toxinidir}
+deps = flake8
+commands = flake8 setup.py git_deps tests
+
+# Options for pytest
+[pytest]
+addopts = -rsxXf