diff options
author | Adam Spiers <git@adamspiers.org> | 2016-06-11 22:20:04 +0100 |
---|---|---|
committer | Adam Spiers <git@adamspiers.org> | 2018-05-15 13:42:16 +0100 |
commit | 2c9d23b0291157eb1096384ff76e0122747b9bdf (patch) | |
tree | 524c7b479b65a478c998c28475d52e636b919200 | |
parent | 9a741f07167dcb6cc81a8f87036d1ea75c4270d3 (diff) | |
download | git-deps-2c9d23b0291157eb1096384ff76e0122747b9bdf.tar.gz |
convert into a proper Python module
Sem-Ver: api-break
-rw-r--r-- | .coveragerc | 23 | ||||
-rw-r--r-- | .gitignore | 41 | ||||
-rw-r--r-- | AUTHORS.rst | 5 | ||||
-rw-r--r-- | CHANGES.rst | 9 | ||||
-rw-r--r-- | INSTALL.md | 81 | ||||
-rw-r--r-- | MANIFEST.in | 1 | ||||
-rw-r--r-- | README.md | 44 | ||||
-rw-r--r-- | docs/Makefile | 177 | ||||
-rw-r--r-- | docs/_static/.gitignore | 1 | ||||
-rw-r--r-- | docs/authors.rst | 2 | ||||
-rw-r--r-- | docs/changes.rst | 2 | ||||
-rw-r--r-- | docs/conf.py | 249 | ||||
-rw-r--r-- | docs/index.rst | 45 | ||||
-rw-r--r-- | docs/license.rst | 7 | ||||
-rwxr-xr-x | git-deps.py | 848 | ||||
-rw-r--r-- | git_deps/__init__.py | 6 | ||||
-rwxr-xr-x | git_deps/cli.py | 148 | ||||
-rw-r--r-- | git_deps/detector.py | 332 | ||||
-rw-r--r-- | git_deps/errors.py | 6 | ||||
-rw-r--r-- | git_deps/gitutils.py | 68 | ||||
-rwxr-xr-x | git_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__.py | 0 | ||||
-rw-r--r-- | git_deps/listener/base.py | 35 | ||||
-rw-r--r-- | git_deps/listener/cli.py | 56 | ||||
-rw-r--r-- | git_deps/listener/json.py | 86 | ||||
-rw-r--r-- | git_deps/server.py | 122 | ||||
-rw-r--r-- | git_deps/utils.py | 8 | ||||
-rw-r--r-- | images/youtube-porting-thumbnail.png (renamed from html/images/youtube-porting-thumbnail.png) | bin | 173700 -> 173700 bytes | |||
-rw-r--r-- | images/youtube-thumbnail.png (renamed from html/images/youtube-thumbnail.png) | bin | 393767 -> 393767 bytes | |||
-rw-r--r-- | requirements.txt | 2 | ||||
-rw-r--r-- | setup.cfg | 71 | ||||
-rw-r--r-- | setup.py | 23 | ||||
-rw-r--r-- | share/gitfile-handler.desktop (renamed from gitfile-handler.desktop) | 0 | ||||
-rw-r--r-- | test-requirements.txt | 5 | ||||
-rw-r--r-- | tests/.gitignore (renamed from test/.gitignore) | 0 | ||||
-rw-r--r-- | tests/conftest.py | 12 | ||||
-rwxr-xr-x | tests/create-repo.sh (renamed from test/create-repo.sh) | 0 | ||||
-rw-r--r-- | tests/test_skeleton.py | 17 | ||||
-rw-r--r-- | tox.ini | 25 |
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 @@ -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 Binary files differindex e4de0c8..e4de0c8 100644 --- a/html/images/youtube-porting-thumbnail.png +++ b/images/youtube-porting-thumbnail.png diff --git a/html/images/youtube-thumbnail.png b/images/youtube-thumbnail.png Binary files differindex 25b0894..25b0894 100644 --- a/html/images/youtube-thumbnail.png +++ b/images/youtube-thumbnail.png 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) @@ -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 |