├── .codeclimate.yml ├── .coveragerc ├── .github └── workflows │ └── CI.yml ├── .gitignore ├── .snapcraft.yaml ├── AUTHORS.rst ├── CHANGES.rst ├── CONTRIBUTING.md ├── HISTORY.md ├── INSTALL.md ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── USAGE.md ├── USE-CASES.md ├── bin └── git-fixup ├── docs ├── Makefile ├── _static │ └── .gitignore ├── authors.rst ├── changes.rst ├── conf.py ├── index.rst ├── license.rst └── maintainer-guide.rst ├── git_deps ├── __init__.py ├── blame.py ├── cli.py ├── detector.py ├── errors.py ├── gitutils.py ├── handler.py ├── html │ ├── .gitignore │ ├── css │ │ ├── animate.css │ │ ├── git-deps-tips.css │ │ └── git-deps.css │ ├── git-deps.html │ ├── js │ │ ├── .gitignore │ │ ├── fullscreen.js │ │ ├── git-deps-data.coffee │ │ ├── git-deps-graph.coffee │ │ ├── git-deps-layout.coffee │ │ └── git-deps-noty.coffee │ ├── package.json │ ├── test.json │ └── tip-template.html ├── listener │ ├── __init__.py │ ├── base.py │ ├── cli.py │ └── json.py ├── server.py └── utils.py ├── images ├── youtube-porting-thumbnail.png └── youtube-thumbnail.png ├── requirements.txt ├── setup.cfg ├── setup.py ├── share └── gitfile-handler.desktop ├── test-requirements.txt ├── tests ├── .gitignore ├── conftest.py ├── create-repo.sh ├── expected_outputs │ ├── deps_1ba7ad5 │ ├── deps_4f27a1e │ ├── deps_b196757 │ └── recursive_deps_4f27a1e ├── self_test.sh ├── test_GitUtils.py └── test_blame.py └── tox.ini /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | languages: 2 | Ruby: false 3 | JavaScript: true 4 | PHP: false 5 | Python: true 6 | # exclude_paths: 7 | # - "foo/bar.rb" -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | branch = True 4 | source = git_deps 5 | # omit = bad_file.py 6 | 7 | [report] 8 | # Regexes for lines to exclude from consideration 9 | exclude_lines = 10 | # Have to re-enable the standard pragma 11 | pragma: no cover 12 | 13 | # Don't complain about missing debug-only code: 14 | def __repr__ 15 | if self\.debug 16 | 17 | # Don't complain if tests don't hit defensive assertion code: 18 | raise AssertionError 19 | raise NotImplementedError 20 | 21 | # Don't complain if non-runnable code isn't run: 22 | if 0: 23 | if __name__ == .__main__.: 24 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.8", "3.9", "3.10", "3.11"] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | with: 24 | fetch-depth: 0 # fetch the entire repository so that we can use it for our self tests 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | python -m pip install flake8 pytest pygit2 33 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 34 | - name: Lint with flake8 35 | run: | 36 | # stop the build if there are Python syntax errors or undefined names 37 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 38 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 39 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 40 | - name: Install git-deps 41 | run: | 42 | pip install -e . 43 | - name: Test with pytest 44 | run: | 45 | pytest 46 | - name: Run the self-test suite 47 | run: | 48 | tests/self_test.sh 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temporary and binary files 2 | *~ 3 | *.py[cod] 4 | *.so 5 | *.cfg 6 | !setup.cfg 7 | *.orig 8 | *.log 9 | *.pot 10 | __pycache__/* 11 | .cache/* 12 | .*.swp 13 | 14 | # Project files 15 | .ropeproject 16 | .project 17 | .pydevproject 18 | .settings 19 | .idea 20 | 21 | # Package files 22 | *.egg 23 | *.eggs/ 24 | .installed.cfg 25 | *.egg-info 26 | 27 | # Unittest and coverage 28 | htmlcov/* 29 | .coverage 30 | .tox 31 | junit.xml 32 | coverage.xml 33 | /.pytest_cache/ 34 | 35 | # Build and docs folder/files 36 | build/* 37 | dist/* 38 | sdist/* 39 | docs/api/* 40 | docs/_build/* 41 | cover/* 42 | MANIFEST 43 | -------------------------------------------------------------------------------- /.snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: git-deps 2 | version: 0.1 3 | summary: A tool for performing analysis of dependencies between git commits 4 | description: | 5 | git-deps is a tool for performing automatic analysis of dependencies 6 | between commits in a git repository. 7 | 8 | confinement: strict 9 | 10 | apps: 11 | git-deps: 12 | command: git-deps/git-deps.py 13 | plugs: 14 | - home 15 | 16 | parts: 17 | git-deps: 18 | plugin: copy 19 | source: . 20 | files: 21 | .: git-deps 22 | stage-packages: 23 | - git 24 | - python2.7 25 | - python-pygit2 26 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Developers 3 | ========== 4 | 5 | `git-deps` was written by Adam Spiers . 6 | 7 | Contributions from others can be seen in the git history, and at 8 | https://github.com/aspiers/git-deps/. 9 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | Version 1.1.0 6 | ============= 7 | 8 | - Improve support for Python 3. 9 | 10 | - ALGORITHM CHANGE: only diff tree with first parent. 11 | 12 | Running ``git deps`` on ``FOO^!`` is effectively answering the 13 | question "which commits would I need in order to be able to cleanly 14 | cherry-pick commit ``FOO``?" Drilling down further, that could be 15 | rephrased more precisely as "which commits would I need in my 16 | current branch in order to be able to cleanly apply the diff which 17 | commit ``FOO`` applies to its parent?" 18 | 19 | However, in the case where ``FOO`` is a merge commit with multiple 20 | parents, typically the first parent ``P1`` is the parent which is 21 | contained by the merge's target branch ``B1``. That means that the 22 | merge commit ``FOO`` has the effect of applying the diff between 23 | ``P1``'s tree and the ``FOO``'s tree to ``P1``. This could be 24 | expressed as:: 25 | 26 | tree(P1) + diff(tree(P1), tree(FOO)) == tree(FOO) 27 | 28 | Therefore the question ``git deps`` needs to answer when operating 29 | on a commit with multiple parents is "which commits would I need in 30 | my current branch in order to be able to cleanly apply 31 | ``diff(tree(P1), tree(FOO))`` to it?" 32 | 33 | However, the current algorithm runs the blame analysis not only on 34 | ``diff(tree(P1), tree(FOO))``, but on ``diff(tree(Px), tree(FOO))`` 35 | for *every* parent. This is problematic, because for instance if 36 | the target branch contains commits which are not on ``P2``'s 37 | branch, then:: 38 | 39 | diff(tree(P2), tree(FOO)) 40 | 41 | will regress any changes provided by those commits. This will 42 | introduce extra dependencies which incorrectly answer the above 43 | question we are trying to answer. 44 | 45 | Therefore change the algorithm to only diff against the first parent. 46 | 47 | This is very similar in nature to the ``-m`` option of ``git cherry-pick``: 48 | 49 | https://stackoverflow.com/questions/12626754/git-cherry-pick-syntax-and-merge-branches/12628579#12628579 50 | 51 | In the future it may be desirable to add an analogous ``-m`` option 52 | to ``git deps``. 53 | 54 | - Add ``git-fixup``. 55 | 56 | - Allow clean interruption via ``Control+C``. 57 | 58 | - Fix output buffering issue. 59 | 60 | - Upgrade jQuery. 61 | 62 | - Improve debugging output. 63 | 64 | - Refactor internals. 65 | 66 | - Improve documentation. 67 | 68 | Version 1.0.2 69 | ============= 70 | 71 | - Improve documentation. 72 | 73 | - Add a guide for maintainers. 74 | 75 | - Add a tox environment for sdist building. 76 | 77 | Version 1.0.1 78 | ============= 79 | 80 | - Update dagre Javascript module to address security issues. 81 | 82 | - Documentation improvements. 83 | 84 | - Avoid PyScaffold bug. 85 | 86 | - Repackage Javascript modules using newer npm, to avoid problem 87 | with timestamps which causes building of wheels to fail: 88 | https://github.com/npm/npm/issues/19968 89 | 90 | Version 1.0.0 91 | ============= 92 | 93 | - Turned into a proper Python module, using PyScaffold. 94 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `git-deps` 2 | 3 | ## Issue tracking 4 | 5 | Any kind of feedback is very welcome; please first check that your bug 6 | / issue / enhancement request is not already listed here: 7 | 8 | * https://github.com/aspiers/git-deps/issues 9 | 10 | and if not then file a new issue. 11 | 12 | ## Helping with development 13 | 14 | Any [pull request](https://help.github.com/articles/using-pull-requests/) 15 | providing an enhancement or bugfix is extremely welcome! 16 | 17 | However my spare time to work on this project is very limited, so 18 | please follow these 19 | [guidelines on contributing](http://blog.adamspiers.org/2012/11/10/7-principles-for-contributing-patches-to-software-projects/) so that you can help me to help you ;-) 20 | 21 | Thanks in advance! 22 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | History of `git-deps` 2 | ======================= 3 | 4 | This tool was born from experiences at 5 | [SUSEcon](http://www.susecon.com/) 2013, when I attempted to help a 6 | colleague backport a bugfix in [OpenStack](http://www.openstack.org/) 7 | [Nova](http://docs.openstack.org/developer/nova/) from the `master` 8 | branch to a stable release branch. At first sight it looked like it 9 | would only require a trivial `git cherry-pick`, but that immediately 10 | revealed conflicts due to related code having changed in `master` 11 | since the release was made. I manually found the underlying commit 12 | which the bugfix required by using `git blame`, and tried another 13 | `cherry-pick`. The same thing happened again. Very soon I found 14 | myself in a quagmire of dependencies between commits, with no idea 15 | whether the end was in sight. 16 | 17 | In coffee breaks during the ensuing openSUSE conference at the same 18 | venue, I feverishly hacked together a prototype and it seemed to work. 19 | Then normal life intervened, and no progress was made for another 20 | year. 21 | 22 | Thanks to SUSE's generous [Hack Week](https://hackweek.suse.com/) 23 | policy, I had the luxury of being able to spending some of early 24 | January 2015 working to bring this tool to the next level. I 25 | submitted a 26 | [Hack Week project page](https://hackweek.suse.com/11/projects/366) 27 | and 28 | [announced my intentions on the `git` mailing list](http://article.gmane.org/gmane.comp.version-control.git/262000). 29 | 30 | Again in May 2018 I took advantage of another Hack Week to package 31 | `git-deps` properly as a Python module in order to improve the 32 | installation process. This was in preparation for demonstrating the 33 | software at [a Meetup 34 | event](https://www.meetup.com/londongit/events/248694943/) of the [Git 35 | London User Group](https://www.meetup.com/londongit/). 36 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | `git-deps` requires [pygit2](http://www.pygit2.org/), which in return 5 | requires [libgit2](https://libgit2.github.com/). `git-deps` and 6 | pygit2 are both Python modules, but libgit2 is not. This means 7 | that there are a few ways to approach installation, detailed below. 8 | Corrections and additions to these instructions are very welcome! 9 | 10 | Before you pick an option, it is very important to consider that [only 11 | certain combinations of libgit2 and pygit2 will work 12 | together](http://www.pygit2.org/install.html#version-numbers). 13 | 14 | Also, Python < 3.7 is no longer supported for `git-deps` (since pygit2 15 | requires 3.7 or higher). 16 | 17 | ## Option 0 (easiest): let `pip` take care of everything 18 | 19 | As mentioned in [`pygit2`'s installation 20 | instructions](https://www.pygit2.org/install.html), `pip` 19.0 and 21 | later can install binary wheels of `pygit2` which include `libgit2`. 22 | This makes installation considerably easier, and should be as simple 23 | as: 24 | 25 | sudo pip3 install git-deps 26 | 27 | or just for the current user: 28 | 29 | pip3 install --user git-deps 30 | 31 | For a per-user install, you will probably have to also ensure that you 32 | have `~/.local/bin` on your path. See [the `pip` 33 | documentation](https://pip.pypa.io/en/stable/) if you are unsure how 34 | this works. 35 | 36 | Also note that it may be `pip` rather than `pip3` on your system, but 37 | if so run `pip --version` to make sure that you aren't getting a 38 | Python 2.x environment by mistake. 39 | 40 | ## Option 1: Install pygit2 and libgit2 from OS packages, and `git-deps` as a Python module 41 | 42 | ### Install OS packages 43 | 44 | if you are using Linux, there is a good chance that your distribution 45 | already offers packages for both pygit2 and libgit2, in which case 46 | installing pygit2 from packages should also automatically install 47 | libgit2. For example, on openSUSE, just do something like: 48 | 49 | sudo zypper install python38-pygit2 50 | 51 | Note that this assumes Python 3.8, which is the latest at the time of 52 | writing. 53 | 54 | Similarly, on Debian: 55 | 56 | sudo apt-get install python3-pygit2 57 | 58 | pygit2's website also has installation instructions for 59 | [Windows](http://www.pygit2.org/install.html#installing-on-windows) 60 | and [Mac OS](http://www.pygit2.org/install.html#installing-on-os-x). 61 | 62 | ### Install `git-deps` via `pip` 63 | 64 | Finally, install `git-deps` via `pip`, for example system-wide on 65 | Linux via: 66 | 67 | sudo pip3 install git-deps 68 | 69 | or just for the current user: 70 | 71 | pip3 install --user git-deps 72 | 73 | (See the caveats in option 0 above about `pip` vs. `pip3` and per-user 74 | installs.) 75 | 76 | ## Option 2: Install libgit2 from OS packages, and `git-deps` / pygit2 as Python modules 77 | 78 | In this case it may be enough to install libgit2 via your 79 | distribution's packaging tool, e.g. on openSUSE: 80 | 81 | sudo zypper install libgit2-24 82 | 83 | Then install `git-deps` via `pip` as described in option 1 above. 84 | This should also automatically install pygit2 as one of its 85 | dependencies. However be aware that this will pick a pygit2 version 86 | based on [`requirements.txt`](requirements.txt) from `git-deps`, which 87 | may not be compatible with the libgit2 you have installed from OS 88 | packages. This can be fixed by telling `pip install` which version of 89 | pygit2 you want. For example if you have installed libgit2 90 | 0.24.0, you could do: 91 | 92 | pip install pygit2==0.24 git-deps 93 | 94 | ## Option 3: Install everything from source 95 | 96 | First follow 97 | [the installation instructions for pygit2](http://www.pygit2.org/install.html). 98 | 99 | Then clone this repository and follow the standard Python module 100 | installation route, e.g. 101 | 102 | python setup.py install 103 | 104 | or if you want to hack on git-deps: 105 | 106 | pip install -e . 107 | 108 | ## Option 4: Installation via Docker 109 | 110 | Rather than following the above manual steps, you can try 111 | [an alternative approach created by Paul Wellner Bou which facilitates running `git-deps` in a Docker container](https://github.com/paulwellnerbou/git-deps-docker). 112 | This has been tested on Ubuntu 14.10, where it was used as a way to 113 | circumvent difficulties with installing libgit2 >= 0.22. 114 | 115 | ## Check installation 116 | 117 | Now `git-deps` should be on your `$PATH`, which means that executing 118 | it and also `git deps` (with a space, not a hyphen) should both work. 119 | 120 | ## Install support for web-based graph visualization (`--serve` option) 121 | 122 | The web-based graph visualization code uses Javascript and relies on 123 | many third-party modules. If you've installed `git-deps` via `pip` 124 | then these files should all be magically installed without any extra 125 | effort, so you can skip reading the rest of this section. 126 | 127 | If however you are installing `git-deps` from source and you want to 128 | use the shiny new graph visualization web server functionality, you 129 | will need to fetch these Javascript libraries yourself. Currently 130 | only one approach to installation is listed below, but any Javascript 131 | experts who have suggestions about other ways to install are [warmly 132 | encouraged to submit them](CONTRIBUTING.md). 133 | 134 | * To install the required Javascript libraries, you will need 135 | [`npm`](https://www.npmjs.com/) installed, and then type: 136 | 137 | cd git_deps/html 138 | npm install 139 | node_modules/.bin/browserify -t coffeeify -d js/git-deps-graph.coffee -o js/bundle.js 140 | 141 | (If you are developing `git-deps` then replace `browserify` with 142 | `watchify -v` in order to continually regenerate `bundle.js` 143 | whenever any of the input files change.) 144 | 145 | * Optionally install `browserify` globally so that it's on your 146 | `$PATH` and therefore executable directly rather than having to 147 | specify the `node_modules/.bin` prefix. For example (at least on 148 | Linux) you can use the `-g` option of `npm` by running this as 149 | `root`: 150 | 151 | npm install -g browserify 152 | 153 | * You will need the [Flask](http://flask.pocoo.org/) Python module 154 | installed, but that should have already been taken care of by the 155 | base installation described above (e.g. via `pip`). 156 | 157 | Now you should be able to run `git deps --serve` and point your 158 | browser at the URL it outputs. 159 | 160 | ### Setting up a `gitfile://` URL handler 161 | 162 | It is possible to set a `gitfile://` URL handler so that if you 163 | double-click any commit node on the dependency graph, your browser 164 | will launch that handler with a URL which points to that commit within 165 | the repository path on your local filesystem. So if you configure 166 | your browser desktop environment, you can have a program such as 167 | [`gitk`](http://git-scm.com/docs/gitk) launch for viewing further 168 | details of that commit. Obviously this only makes sense when viewing 169 | the graph via http://localhost. 170 | 171 | On most Linux machines, this can be set up by first locating the 172 | [Desktop 173 | Entry](https://standards.freedesktop.org/desktop-entry-spec/latest/) 174 | file which is provided in the distribution for convenient 175 | installation: 176 | 177 | pip show -f git-deps | grep gitfile-handler.desktop 178 | 179 | Once you have located it, it needs to be copied or symlinked into the 180 | right location, e.g. 181 | 182 | ln -sf /usr/share/git_deps/gitfile-handler.desktop \ 183 | ~/.local/share/applications 184 | 185 | and then the desktop file has to be registered as a handler for the 186 | `gitfile` protocol: 187 | 188 | xdg-mime default gitfile-handler.desktop x-scheme-handler/gitfile 189 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft git_deps/html -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Code Climate](https://codeclimate.com/github/aspiers/git-deps/badges/gpa.svg)](https://codeclimate.com/github/aspiers/git-deps) 2 | 3 | git-deps 4 | ======== 5 | 6 | `git-deps` is a tool for performing automatic analysis of dependencies 7 | between commits in a [git](http://git-scm.com/) repository. Here's 8 | a screencast demonstration: 9 | 10 | [![YouTube screencast](./images/youtube-thumbnail.png)](http://youtu.be/irQ5gMMz-gE) 11 | 12 | I have [blogged about `git-deps` and related 13 | tools](https://blog.adamspiers.org/2018/06/14/git-auto-magic/), and 14 | also publically spoken about the tool several times: 15 | 16 | - [a presentation at the openSUSE Summit in Nashville, Apr 2019](https://aspiers.github.io/nashville-git-automagic-april-2019/) 17 | - [a presentation at the OpenStack PTG in Denver, Sept 2018](https://aspiers.github.io/denver-git-automagic-sept-2018/) ([watch the video](https://youtu.be/f6anrSKCIgI)) 18 | - [a presentation at the London Git User Meetup in May 2018](https://aspiers.github.io/london-git-automagic-may-2018/) ([watch the video](https://skillsmatter.com/skillscasts/11825-git-auto-magic)) 19 | - [episode #32 of the GitMinutes podcast in 2015](http://episodes.gitminutes.com/2015/03/gitminutes-32-adam-spiers-on-git-deps.html) 20 | 21 | 22 | Contents 23 | -------- 24 | 25 | - [Background theory](#background-theory) 26 | - [Motivation](#motivation) 27 | - [Use case 1: porting between branches](USE-CASES.md#use-case-1-porting-between-branches) 28 | - [Use case 2: splitting a patch series into independent topics](USE-CASES.md#use-case-2-splitting-a-patch-series-into-independent-topics) 29 | - [Use case 3: aiding collaborative communication](USE-CASES.md#use-case-3-aiding-collaborative-communication) 30 | - [Use case 4: automatic squashing of fixup commits](USE-CASES.md#use-case-4-automatic-squashing-of-fixup-commits) 31 | - [Use case 5: rewriting commit history](USE-CASES.md#use-case-5-rewriting-commit-history) 32 | - [Installation](INSTALL.md) 33 | - [Usage](USAGE.md) 34 | - [Textual vs. semantic (in)dependence](#textual-vs-semantic-independence) 35 | - [Development / support / feedback](#development--support--feedback) 36 | - [History](HISTORY.md) 37 | - [Credits](#credits) 38 | - [License](#license) 39 | 40 | 41 | Background theory 42 | ----------------- 43 | 44 | It is fairly clear that two git commits within a single repo can be 45 | considered "independent" from each other in a certain sense, if they 46 | do not change the same files, or if they do not change overlapping 47 | parts of the same file(s). 48 | 49 | In contrast, when a commit changes a line, it is "dependent" on not 50 | only the commit which last changed that line, but also any commits 51 | which were responsible for providing the surrounding lines of context, 52 | because without those previous versions of the line and its context, 53 | the commit's diff might not cleanly apply (depending on how it's being 54 | applied, of course). So all dependencies of a commit can be 55 | programmatically inferred by running git-blame on the lines the commit 56 | changes, plus however many lines of context make sense for the use 57 | case of this particular dependency analysis. 58 | 59 | Therefore the dependency calculation is impacted by a "fuzz" factor 60 | parameter 61 | (c.f. [patch(1)](http://en.wikipedia.org/wiki/Patch_(Unix))), i.e. the 62 | number of lines of context which are considered necessary for the 63 | commit's diff to cleanly apply. 64 | 65 | As with many dependency relationships, these dependencies form edges 66 | in a DAG (directed acyclic graph) whose nodes correspond to commits. 67 | Note that a node can only depend on a subset of its ancestors. 68 | 69 | ### Caveat 70 | 71 | It is important to be aware that any dependency graph inferred by 72 | `git-deps` may be semantically incomplete; for example it would not 73 | auto-detect dependencies between a commit A which changes code and 74 | another commit B which changes documentation or tests to reflect the 75 | code changes in commit A. Therefore `git-deps` should not be used 76 | with blind faith. For more details, see [the section on Textual 77 | vs. semantic (in)dependence](#textual-vs-semantic-independence) below. 78 | 79 | 80 | Motivation 81 | ---------- 82 | 83 | Sometimes it is useful to understand the nature of parts of this 84 | dependency graph, as its nature will impact the success or failure of 85 | operations including merge, rebase, cherry-pick etc. Please see [the 86 | `USE-CASES.md` file](USE-CASES.md) for more details. 87 | 88 | 89 | Installation 90 | ------------ 91 | 92 | Please see [the `INSTALL.md` file](INSTALL.md). 93 | 94 | 95 | Usage 96 | ----- 97 | 98 | Please see [the `USAGE.md` file](USAGE.md). 99 | 100 | 101 | Textual vs. semantic (in)dependence 102 | ----------------------------------- 103 | 104 | Astute readers will note that textual independence as detected by 105 | `git-deps` is not the same as semantic / logical independence. 106 | Textual independence means that the changes can be applied in any 107 | order without incurring conflicts, but this is not a reliable 108 | indicator of logical independence. 109 | 110 | For example a change to a function and corresponding changes to the 111 | tests and/or documentation for that function would typically exist in 112 | different files. So if those changes were in separate commits within 113 | a branch, running `git-deps` on the commits would not detect any 114 | dependency between them even though they are logically related, 115 | because changes in different files (or even in different areas of the 116 | same files) are textually independent. 117 | 118 | So in this case, `git-deps` would not behave exactly how we might 119 | want. And for as long as AI is an unsolved problem, it is very 120 | unlikely that it will ever develop totally reliable behaviour. So 121 | does that mean `git-deps` is useless? Absolutely not! 122 | 123 | Firstly, when [best 124 | practices](https://crealytics.com/blog/5-reasons-keeping-git-commits-small/) 125 | for [commit 126 | structuring](https://wiki.openstack.org/wiki/GitCommitMessages#Structural_split_of_changes) 127 | are adhered to, changes which are strongly logically related should be 128 | placed within the same commit anyway. So in the example above, a 129 | change to a function and corresponding changes to the tests and/or 130 | documentation for that function should all be within a single commit. 131 | (Although this is not the only valid approach; for a more advanced 132 | meta-history grouping mechanism, see 133 | [`git-dendrify`](https://github.com/bennorth/git-dendrify).) 134 | 135 | Secondly, whilst textual independence does not imply logical 136 | independence, the converse is expected to be more commonly true: 137 | logical independence often implies textual independence (or stated 138 | another way, textual dependence often implies logical dependence). So 139 | while it might not be too uncommon for `git-deps` to fail to detect 140 | the dependency between logically-related changes, it should be rarer 141 | that it incorrectly infers a dependency between logically unrelated 142 | changes. In other words, its false negatives are generally expected 143 | to be more common than its false positives. As a result it is likely 144 | to be more useful in determining a lower bound on dependencies than an 145 | upper bound. Having said that, more research is needed on this. 146 | 147 | Thirdly, it is often unhelpful to allow [the quest for the perfect 148 | become the enemy of the 149 | good](https://en.wikipedia.org/wiki/Perfect_is_the_enemy_of_good) - a 150 | tool does not have to be perfect to be useful; it only has to be 151 | better than performing the same task without the tool. 152 | 153 | Further discussion on some of these points can be found in [an old 154 | thread from the git mailing 155 | list](https://public-inbox.org/git/20160528112417.GD11256@pacific.linksys.moosehall/). 156 | 157 | Ultimately though, ["the proof is in the 158 | pudding"](https://en.wiktionary.org/wiki/the_proof_is_in_the_pudding), 159 | so try it out and see! 160 | 161 | 162 | Development / support / feedback 163 | -------------------------------- 164 | 165 | Please see [the `CONTRIBUTING.md` file](CONTRIBUTING.md). 166 | 167 | 168 | History 169 | ------- 170 | 171 | Please see [the `HISTORY.md` file](HISTORY.md). 172 | 173 | 174 | Credits 175 | ------ 176 | 177 | Special thanks to [SUSE](https://suse.com) for partially sponsoring 178 | the development of this software. Thanks also to everyone who has 179 | contributed code, bug reports, and other feedback. 180 | 181 | 182 | License 183 | ------- 184 | 185 | Released under [GPL version 2](LICENSE.txt) in order to be consistent with 186 | [`git`'s license](https://github.com/git/git/blob/master/COPYING), but 187 | I'm open to the idea of dual-licensing if there's a convincing reason. 188 | -------------------------------------------------------------------------------- /USAGE.md: -------------------------------------------------------------------------------- 1 | How to use `git-deps` 2 | ======================= 3 | 4 | Usage is fairly self-explanatory if you run `git deps -h`: 5 | 6 | ``` 7 | usage: git-deps [options] COMMIT-ISH [COMMIT-ISH...] 8 | 9 | Auto-detects commits on which the given commit(s) depend. 10 | 11 | optional arguments: 12 | -h, --help Show this help message and exit 13 | -v, --version show program's version number and exit 14 | -l, --log Show commit logs for calculated dependencies 15 | -j, --json Output dependencies as JSON 16 | -s, --serve Run a web server for visualizing the dependency graph 17 | -b IP, --bind-ip IP IP address for webserver to bind to [127.0.0.1] 18 | -p PORT, --port PORT Port number for webserver [5000] 19 | -r, --recurse Follow dependencies recursively 20 | -e COMMITISH, --exclude-commits COMMITISH 21 | Exclude commits which are ancestors of the given COMMITISH (can be repeated) 22 | -c NUM, --context-lines NUM 23 | Number of lines of diff context to use [1] 24 | -d, --debug Show debugging 25 | ``` 26 | 27 | Currently you should run it from the root (i.e. top directory) of the 28 | git repository you want to examine; this is a 29 | [known limitation](https://github.com/aspiers/git-deps/issues/27). 30 | 31 | By default it will output the SHA1s of all dependencies of the given 32 | commit-ish(s), one per line. With `--recurse`, it will traverse 33 | dependencies of dependencies, and so on until it cannot find any more. 34 | In recursion mode, two SHA1s are output per line, indicating that the 35 | first depends on the second. 36 | 37 | 38 | Web UI for visualizing and navigating the dependency graph 39 | ---------------------------------------------------------- 40 | 41 | If you run it with the `--serve` option and no COMMIT-ISH parameters, 42 | then it will start a lightweight webserver and output a URL you can 43 | connect to for dynamically visualizing and navigating the dependency 44 | graph. 45 | 46 | Optionally choose a commit-ish (the form defaults to `master`), click 47 | the `Submit` button, and you should see a graph appear with one node 48 | per commit. By hovering the mouse over a node you will see more 49 | details, and a little `+` icon will appear which can be clicked to 50 | calculate dependencies of that commit, further growing the dependency 51 | tree. You can zoom in and out with the mousewheel, and drag the 52 | background to pan around. 53 | 54 | If you set up a MIME handler for the `gitfile://` protocol during 55 | setup, [as documented](INSTALL.md) you will be able to double-click on 56 | nodes to launch a viewer to inspect individual commits in more detail. 57 | -------------------------------------------------------------------------------- /USE-CASES.md: -------------------------------------------------------------------------------- 1 | `git-deps` use cases 2 | ====================== 3 | 4 | Several use cases for `git-deps` are listed in detail below. They are 5 | also [mentioned in the presentation I gave in September 6 | 2018](https://aspiers.github.io/denver-git-automagic-sept-2018/#/git-deps-motivation) 7 | (see also [the video](https://youtu.be/f6anrSKCIgI?t=216)). 8 | 9 | - [Use case 1: porting between branches](#use-case-1-porting-between-branches) 10 | - [Use case 2: splitting a patch series into independent topics](#use-case-2-splitting-a-patch-series-into-independent-topics) 11 | - [Use case 3: aiding collaborative communication](#use-case-3-aiding-collaborative-communication) 12 | - [Use case 4: automatic squashing of fixup commits](#use-case-4-automatic-squashing-of-fixup-commits) 13 | - [Use case 5: rewriting commit history](#use-case-5-rewriting-commit-history) 14 | 15 | ### Use case 1: porting between branches 16 | 17 | For example when porting a commit "A" between git branches via `git 18 | cherry-pick`, it can be useful to programmatically determine in advance 19 | the minimum number of other dependent commits which would also need to 20 | be cherry-picked to provide the context for commit "A" to cleanly 21 | apply. Here's a quick demo! 22 | 23 | [![YouTube porting screencast](./images/youtube-porting-thumbnail.png)](http://youtu.be/DVksJMXxVIM) 24 | 25 | **CAVEAT**: `git-deps` is not AI and only does a textual dependency 26 | analysis, therefore it does not guarantee there is no simpler way to 27 | backport. It also may infer more dependencies than strictly necessary 28 | due the default setting of one line of fuzz (diff context). Shrinking 29 | this to zero lines may produce a more conservative dependency tree, 30 | but it's also riskier and more likely to cause conflicts or even bad 31 | code on cherry-pick. git-deps just provides a first estimate. 32 | 33 | Therefore combining it with human analysis of the commits in the 34 | dependency tree is strongly recommended. This may reveal 35 | opportunities for selective pruning or other editing of commits during 36 | the backport process which may be able to reduce the number of commits 37 | which are required. 38 | 39 | ### Use case 2: splitting a patch series into independent topics 40 | 41 | Large patch series or pull requests can be quite daunting for project 42 | maintainers, since they are hard to conquer in one sitting. For this 43 | reason it's generally best to keep the number of commits in any 44 | submission reasonably small. However during normal hacking, you might 45 | accumulate a large number of patches before you start to contemplate 46 | submitting any of them upstream. In this case, `git-deps` can help 47 | you determine how to break them up into smaller chunks. Simply run 48 | 49 | git deps -e $upstream_branch -s 50 | 51 | and then create a graph starting from the head of your local 52 | development branch, recursively expanding all the dependencies. This 53 | will allow you to untangle things and expose subgraphs which can be 54 | cleanly split off into separate patch series or pull requests for 55 | submission. 56 | 57 | In fact this technique is sufficiently useful but tedious to do 58 | manually that I wrote a whole separate tool 59 | [`git-explode`](https://github.com/aspiers/git-explode) to automate 60 | the process. It uses `git-deps` as a library module behind the scenes 61 | for the dependency inference. See [the 62 | `README`](https://github.com/aspiers/git-explode/blob/master/README.rst) 63 | for more details. 64 | 65 | ### Use case 3: aiding collaborative communication 66 | 67 | Another use case might be to better understand levels of specialism / 68 | cross-functionality within an agile team. If I author a commit which 69 | modifies (say) lines 34-37 and 102-109 of a file, the authors of the 70 | dependent commits are people I should potentially consider asking to 71 | review my commit, since I'm effectively changing "their" code. 72 | Monitoring those relationships over time might shed some light on how 73 | agile teams should best coordinate efforts on shared code bases. 74 | 75 | ### Use case 4: automatic squashing of fixup commits 76 | 77 | It is often desirable to amend an existing commit which is in the 78 | current branch but not at its head. This can be done by creating a 79 | new commit which amends (only) the existing commit, and then use `git 80 | rebase --interactive` in order to squash the two commits together into 81 | a new one which reuses the commit message from the original. 82 | `git-commit[1]` has a nice feature which makes this process convenient 83 | even when the commit to be amended is not at the head of the current 84 | branch. It is described in [the `git-commit[1]` man 85 | page](https://git-scm.com/docs/git-commit): 86 | 87 | > `--fixup=` 88 | > 89 | > Construct a commit message for use with `rebase --autosquash`. The 90 | > commit message will be the subject line from the specified commit 91 | > with a prefix of `"fixup! "`. See `git-rebase[1]` for details. 92 | 93 | The corresponding details in the [`git-rebase[1]` man 94 | page](https://git-scm.com/docs/git-rebase) are: 95 | 96 | > `--autosquash, --no-autosquash` 97 | > 98 | > When the commit log message begins with `"squash! ..."` (or `"fixup! 99 | > ..."`), and there is already a commit in the todo list that matches 100 | > the same ..., automatically modify the todo list of `rebase -i` so 101 | > that the commit marked for squashing comes right after the commit to 102 | > be modified, and change the action of the moved commit from pick to 103 | > squash (or fixup). A commit matches the ... if the commit subject 104 | > matches, or if the ... refers to the commit’s hash. As a fall-back, 105 | > partial matches of the commit subject work, too. The recommended way 106 | > to create fixup/squash commits is by using the `--fixup`/`--squash` 107 | > options of `git-commit(1)` 108 | 109 | However, this process still requires manually determining which commit 110 | should be passed to the `--fixup` option. Fortunately `git-deps` can 111 | automate this for us. To eliminate this extra work, this repository 112 | provides a simple script which wraps around `git-deps` to automate the 113 | whole process. First the user should ensure that any desired 114 | amendments to the existing commit are staged in git's index. Then 115 | they can run the `git-fixup` script which performs the following 116 | steps: 117 | 118 | 1. These staged amendments to existing commit are committed using 119 | temporary commit message. 120 | 121 | 2. `git deps HEAD^!` is run to determine which previously existing 122 | commit this new commit is intended to "patch". This should only 123 | result in a single dependency, otherwise the script aborts with an 124 | error. 125 | 126 | 3. The temporary commit's message is amended into the correct `fixup` 127 | form. On the next `git rebase --interactive` which includes the 128 | original commit to be amended, `git-rebase` will automatically set 129 | up the sequencer to apply the amendment (fixup) into the original. 130 | 131 | In the future, this script could be extended to optionally run the 132 | interactive `rebase`, so that the whole amendment process is taken 133 | care of by `git-fixup`. 134 | 135 | ### Use case 5: rewriting commit history 136 | 137 | It is often useful to reorder or rewrite commit history within private 138 | branches, as part of a history polishing process which ensures that 139 | eventually published history is of a high quality (see ["On Sausage 140 | Making"](https://sethrobertson.github.io/GitBestPractices/#sausage)). 141 | 142 | However reordering or removing commits can cause conflicts. Whilst 143 | `git-deps` can programmatically predict whether operations such as 144 | merge / rebase / cherry-pick would succeed, actually it's probably 145 | cheaper and more reliable simply to perform the operation and then 146 | roll back. However `git-deps` could be used to detect ways to avoid 147 | these conflicts, for example reordering or removing a commit's 148 | dependencies along with the commit itself. In the future tools could 149 | be built on top of `git-deps` to automate these processes. 150 | 151 | ### Other uses 152 | 153 | I'm sure there are other use cases I haven't yet thought of. If you 154 | have any good ideas, [please submit them](CONTRIBUTING.md)! 155 | -------------------------------------------------------------------------------- /bin/git-fixup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if ! git commit -m 'fixup commit'; then 4 | echo >&2 "Failed to create fixup commit; aborting." 5 | exit 1 6 | fi 7 | 8 | deps=( $( git deps HEAD^! ) ) 9 | if [ ${#deps[@]} != 1 ]; then 10 | echo >&2 "Failed to find a single dependency of the fixup commit; aborting." 11 | git reset --soft HEAD^ 12 | exit 1 13 | fi 14 | 15 | git commit --amend --fixup=$deps 16 | 17 | # TODO: support optionally triggering the rebase. 18 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $?), 1) 12 | $(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/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/git-deps.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/git-deps.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $HOME/.local/share/devhelp/git-deps" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $HOME/.local/share/devhelp/git-deps" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/_static/.gitignore: -------------------------------------------------------------------------------- 1 | # Empty directory 2 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. _authors: 2 | .. include:: ../AUTHORS.rst 3 | -------------------------------------------------------------------------------- /docs/changes.rst: -------------------------------------------------------------------------------- 1 | .. _changes: 2 | .. include:: ../CHANGES.rst 3 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is execfile()d with the current directory set to its containing dir. 4 | # 5 | # Note that not all possible configuration values are present in this 6 | # autogenerated file. 7 | # 8 | # All configuration values have a default; values that are commented out 9 | # serve to show the default. 10 | 11 | import sys 12 | 13 | # If extensions (or modules to document with autodoc) are in another directory, 14 | # add these directories to sys.path here. If the directory is relative to the 15 | # documentation root, use os.path.abspath to make it absolute, like shown here. 16 | # sys.path.insert(0, os.path.abspath('.')) 17 | 18 | # -- Hack for ReadTheDocs ------------------------------------------------------ 19 | # This hack is necessary since RTD does not issue `sphinx-apidoc` before running 20 | # `sphinx-build -b html . _build/html`. See Issue: 21 | # https://github.com/rtfd/readthedocs.org/issues/1139 22 | # DON'T FORGET: Check the box "Install your project inside a virtualenv using 23 | # setup.py install" in the RTD Advanced Settings. 24 | import os 25 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 26 | if on_rtd: 27 | import inspect 28 | from sphinx import apidoc 29 | 30 | __location__ = os.path.join(os.getcwd(), os.path.dirname( 31 | inspect.getfile(inspect.currentframe()))) 32 | 33 | output_dir = os.path.join(__location__, "../docs/api") 34 | module_dir = os.path.join(__location__, "../git_deps") 35 | cmd_line_template = "sphinx-apidoc -f -o {outputdir} {moduledir}" 36 | cmd_line = cmd_line_template.format(outputdir=output_dir, moduledir=module_dir) 37 | apidoc.main(cmd_line.split(" ")) 38 | 39 | # -- General configuration ----------------------------------------------------- 40 | 41 | # If your documentation needs a minimal Sphinx version, state it here. 42 | # needs_sphinx = '1.0' 43 | 44 | # Add any Sphinx extension module names here, as strings. They can be extensions 45 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 46 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.todo', 47 | 'sphinx.ext.autosummary', 'sphinx.ext.viewcode', 'sphinx.ext.coverage', 48 | 'sphinx.ext.doctest', 'sphinx.ext.ifconfig', 49 | 'sphinx.ext.napoleon'] 50 | 51 | # Add any paths that contain templates here, relative to this directory. 52 | templates_path = ['_templates'] 53 | 54 | # The suffix of source filenames. 55 | source_suffix = '.rst' 56 | 57 | # The encoding of source files. 58 | # source_encoding = 'utf-8-sig' 59 | 60 | # The master toctree document. 61 | master_doc = 'index' 62 | 63 | # General information about the project. 64 | project = u'git-deps' 65 | copyright = u'2016, Adam Spiers' 66 | 67 | # The version info for the project you're documenting, acts as replacement for 68 | # |version| and |release|, also used in various other places throughout the 69 | # built documents. 70 | # 71 | # The short X.Y version. 72 | version = '' # Is set by calling `setup.py docs` 73 | # The full version, including alpha/beta/rc tags. 74 | release = '' # Is set by calling `setup.py docs` 75 | 76 | # The language for content autogenerated by Sphinx. Refer to documentation 77 | # for a list of supported languages. 78 | # language = None 79 | 80 | # There are two options for replacing |today|: either, you set today to some 81 | # non-false value, then it is used: 82 | # today = '' 83 | # Else, today_fmt is used as the format for a strftime call. 84 | # today_fmt = '%B %d, %Y' 85 | 86 | # List of patterns, relative to source directory, that match files and 87 | # directories to ignore when looking for source files. 88 | exclude_patterns = ['_build'] 89 | 90 | # The reST default role (used for this markup: `text`) to use for all documents. 91 | # default_role = None 92 | 93 | # If true, '()' will be appended to :func: etc. cross-reference text. 94 | # add_function_parentheses = True 95 | 96 | # If true, the current module name will be prepended to all description 97 | # unit titles (such as .. function::). 98 | # add_module_names = True 99 | 100 | # If true, sectionauthor and moduleauthor directives will be shown in the 101 | # output. They are ignored by default. 102 | # show_authors = False 103 | 104 | # The name of the Pygments (syntax highlighting) style to use. 105 | pygments_style = 'sphinx' 106 | 107 | # A list of ignored prefixes for module index sorting. 108 | # modindex_common_prefix = [] 109 | 110 | # If true, keep warnings as "system message" paragraphs in the built documents. 111 | # keep_warnings = False 112 | 113 | 114 | # -- Options for HTML output --------------------------------------------------- 115 | 116 | # The theme to use for HTML and HTML Help pages. See the documentation for 117 | # a list of builtin themes. 118 | html_theme = 'alabaster' 119 | 120 | # Theme options are theme-specific and customize the look and feel of a theme 121 | # further. For a list of options available for each theme, see the 122 | # documentation. 123 | # html_theme_options = {} 124 | 125 | # Add any paths that contain custom themes here, relative to this directory. 126 | # html_theme_path = [] 127 | 128 | # The name for this set of Sphinx documents. If None, it defaults to 129 | # " v documentation". 130 | try: 131 | from git_deps import __version__ as version 132 | except ImportError: 133 | pass 134 | else: 135 | release = version 136 | 137 | # A shorter title for the navigation bar. Default is the same as html_title. 138 | # html_short_title = None 139 | 140 | # The name of an image file (relative to this directory) to place at the top 141 | # of the sidebar. 142 | # html_logo = "" 143 | 144 | # The name of an image file (within the static path) to use as favicon of the 145 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 146 | # pixels large. 147 | # html_favicon = None 148 | 149 | # Add any paths that contain custom static files (such as style sheets) here, 150 | # relative to this directory. They are copied after the builtin static files, 151 | # so a file named "default.css" will overwrite the builtin "default.css". 152 | html_static_path = ['_static'] 153 | 154 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 155 | # using the given strftime format. 156 | # html_last_updated_fmt = '%b %d, %Y' 157 | 158 | # If true, SmartyPants will be used to convert quotes and dashes to 159 | # typographically correct entities. 160 | # html_use_smartypants = True 161 | 162 | # Custom sidebar templates, maps document names to template names. 163 | # html_sidebars = {} 164 | 165 | # Additional templates that should be rendered to pages, maps page names to 166 | # template names. 167 | # html_additional_pages = {} 168 | 169 | # If false, no module index is generated. 170 | # html_domain_indices = True 171 | 172 | # If false, no index is generated. 173 | # html_use_index = True 174 | 175 | # If true, the index is split into individual pages for each letter. 176 | # html_split_index = False 177 | 178 | # If true, links to the reST sources are added to the pages. 179 | # html_show_sourcelink = True 180 | 181 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 182 | # html_show_sphinx = True 183 | 184 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 185 | # html_show_copyright = True 186 | 187 | # If true, an OpenSearch description file will be output, and all pages will 188 | # contain a tag referring to it. The value of this option must be the 189 | # base URL from which the finished HTML is served. 190 | # html_use_opensearch = '' 191 | 192 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 193 | # html_file_suffix = None 194 | 195 | # Output file base name for HTML help builder. 196 | htmlhelp_basename = 'git_deps-doc' 197 | 198 | 199 | # -- Options for LaTeX output -------------------------------------------------- 200 | 201 | latex_elements = { 202 | # The paper size ('letterpaper' or 'a4paper'). 203 | # 'papersize': 'letterpaper', 204 | 205 | # The font size ('10pt', '11pt' or '12pt'). 206 | # 'pointsize': '10pt', 207 | 208 | # Additional stuff for the LaTeX preamble. 209 | # 'preamble': '', 210 | } 211 | 212 | # Grouping the document tree into LaTeX files. List of tuples 213 | # (source start file, target name, title, author, documentclass [howto/manual]). 214 | latex_documents = [ 215 | ('index', 'user_guide.tex', u'git-deps Documentation', 216 | u'Adam Spiers', 'manual'), 217 | ] 218 | 219 | # The name of an image file (relative to this directory) to place at the top of 220 | # the title page. 221 | # latex_logo = "" 222 | 223 | # For "manual" documents, if this is true, then toplevel headings are parts, 224 | # not chapters. 225 | # latex_use_parts = False 226 | 227 | # If true, show page references after internal links. 228 | # latex_show_pagerefs = False 229 | 230 | # If true, show URL addresses after external links. 231 | # latex_show_urls = False 232 | 233 | # Documents to append as an appendix to all manuals. 234 | # latex_appendices = [] 235 | 236 | # If false, no module index is generated. 237 | # latex_domain_indices = True 238 | 239 | # -- External mapping ------------------------------------------------------------ 240 | python_version = '.'.join(map(str, sys.version_info[0:2])) 241 | intersphinx_mapping = { 242 | 'sphinx': ('https://www.sphinx-doc.org/en/master', None), 243 | 'python': ('http://docs.python.org/' + python_version, None), 244 | 'matplotlib': ('http://matplotlib.sourceforge.net', None), 245 | 'numpy': ('http://docs.scipy.org/doc/numpy', None), 246 | 'sklearn': ('http://scikit-learn.org/stable', None), 247 | 'pandas': ('http://pandas.pydata.org/pandas-docs/stable', None), 248 | 'scipy': ('http://docs.scipy.org/doc/scipy/reference/', None), 249 | } 250 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | git-deps 3 | ======== 4 | 5 | This will become the main documentation of **git-deps**. 6 | 7 | Unfortunately it hasn't been written yet, but in the mean time there 8 | is plenty of useful information on `the GitHub project 9 | `_. 10 | 11 | 12 | Contents 13 | ======== 14 | 15 | .. toctree:: 16 | :maxdepth: 2 17 | 18 | Changelog 19 | Module Reference 20 | Maintainer's Guide 21 | Authors 22 | License 23 | 24 | 25 | Indices and tables 26 | ================== 27 | 28 | * :ref:`genindex` 29 | * :ref:`modindex` 30 | * :ref:`search` 31 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | .. _license: 2 | 3 | ======= 4 | License 5 | ======= 6 | 7 | .. literalinclude:: ../LICENSE.txt 8 | -------------------------------------------------------------------------------- /docs/maintainer-guide.rst: -------------------------------------------------------------------------------- 1 | .. _release: 2 | 3 | ================== 4 | Maintainer guide 5 | ================== 6 | 7 | This guide contains a playbook for tasks the maintainer will need to 8 | perform. 9 | 10 | 11 | Initial setup 12 | ============= 13 | 14 | - Create a PyPI account 15 | 16 | - Configure ``~/.pypirc`` with credentials 17 | 18 | - ``pip install twine`` 19 | 20 | 21 | How to make a new release of git-deps 22 | ===================================== 23 | 24 | - Ensure everything is committed and the git working tree is clean. 25 | 26 | - Ensure all change have been pushed to the remote branch. 27 | 28 | - Run ``tox`` to check everything is OK. 29 | 30 | - Decide a new version, e.g.:: 31 | 32 | version=1.1.0 33 | 34 | Release candidates should take the form ``1.2.3rc4``. 35 | 36 | - ``git tag -s $version`` 37 | 38 | - ``tox -e sdist`` 39 | 40 | - ``twine upload .tox/dist/git-deps-$version.zip`` 41 | 42 | - Check the new version appears at ``_. 43 | 44 | - Test installation via at least one of the documented options, e.g. 45 | ``pip install git-deps`` within a virtualenv. 46 | 47 | - Test / update the Docker-based installation. 48 | -------------------------------------------------------------------------------- /git_deps/__init__.py: -------------------------------------------------------------------------------- 1 | import pkg_resources 2 | 3 | try: 4 | __version__ = pkg_resources.get_distribution(__name__).version 5 | except pkg_resources.DistributionNotFound: 6 | __version__ = 'unknown' 7 | -------------------------------------------------------------------------------- /git_deps/blame.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import re 3 | from dataclasses import dataclass 4 | 5 | # The following classes are introduced to imitate their counterparts in pygit2, 6 | # so that the output of 'blame_via_subprocess' can be swapped with pygit2's own 7 | # blame output. 8 | 9 | @dataclass 10 | class GitRef: 11 | """ 12 | A reference to a commit 13 | """ 14 | hex: str 15 | 16 | @dataclass 17 | class BlameHunk: 18 | """ 19 | A chunk of a blame output which has the same commit information 20 | for a consecutive set of lines 21 | """ 22 | orig_commit_id: GitRef 23 | orig_start_line_number: int 24 | final_start_line_number: int 25 | lines_in_hunk: int = 1 26 | 27 | 28 | def blame_via_subprocess(path, commit, start_line, num_lines): 29 | """ 30 | Generate a list of blame hunks by calling 'git blame' as a separate process. 31 | This is a workaround for the slowness of pygit2's own blame algorithm. 32 | See https://github.com/aspiers/git-deps/issues/1 33 | """ 34 | cmd = [ 35 | 'git', 'blame', 36 | '--porcelain', 37 | '-L', "%d,+%d" % (start_line, num_lines), 38 | commit, '--', path 39 | ] 40 | output = subprocess.check_output(cmd, universal_newlines=True) 41 | 42 | current_hunk = None 43 | for line in output.split('\n'): 44 | m = re.match(r'^([0-9a-f]{40}) (\d+) (\d+) (\d+)$', line) 45 | 46 | if m: # starting a new hunk 47 | if current_hunk: 48 | yield current_hunk 49 | dependency_sha1, orig_line_num, line_num, length = m.group(1, 2, 3, 4) 50 | orig_line_num = int(orig_line_num) 51 | line_num = int(line_num) 52 | length = int(length) 53 | current_hunk = BlameHunk( 54 | orig_commit_id=GitRef(dependency_sha1), 55 | orig_start_line_number = orig_line_num, 56 | final_start_line_number = line_num, 57 | lines_in_hunk = length 58 | ) 59 | 60 | if current_hunk: 61 | yield current_hunk 62 | -------------------------------------------------------------------------------- /git_deps/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | git-deps - automatically detect dependencies between git commits 5 | Copyright (C) 2013 Adam Spiers 6 | 7 | The software in this repository is free software: you can redistribute 8 | it and/or modify it under the terms of the GNU General Public License 9 | as published by the Free Software Foundation, either version 2 of the 10 | License, or (at your option) any later version. 11 | 12 | This software is distributed in the hope that it will be useful, but 13 | WITHOUT ANY WARRANTY; without even the implied warranty of 14 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 15 | General Public License for more details. 16 | 17 | You should have received a copy of the GNU General Public License 18 | along with this program. If not, see . 19 | """ 20 | 21 | from __future__ import print_function 22 | 23 | import argparse 24 | import json 25 | import os 26 | import sys 27 | 28 | from git_deps import __version__ 29 | from git_deps.detector import DependencyDetector 30 | from git_deps.errors import InvalidCommitish 31 | from git_deps.gitutils import GitUtils 32 | from git_deps.listener.json import JSONDependencyListener 33 | from git_deps.listener.cli import CLIDependencyListener 34 | from git_deps.server import serve 35 | from git_deps.utils import abort 36 | 37 | 38 | def parse_args(): 39 | ##################################################################### 40 | # REMINDER!! If you change this, remember to update README.md too. 41 | ##################################################################### 42 | parser = argparse.ArgumentParser( 43 | description='Auto-detects commits on which the given ' 44 | 'commit(s) depend.', 45 | usage='%(prog)s [options] COMMIT-ISH [COMMIT-ISH...]', 46 | add_help=False 47 | ) 48 | parser.add_argument('-h', '--help', action='help', 49 | help='Show this help message and exit') 50 | parser.add_argument('-v', '--version', action='version', 51 | version='git-deps {ver}'.format(ver=__version__)) 52 | parser.add_argument('-l', '--log', dest='log', action='store_true', 53 | help='Show commit logs for calculated dependencies') 54 | parser.add_argument('-j', '--json', dest='json', action='store_true', 55 | help='Output dependencies as JSON') 56 | parser.add_argument('-s', '--serve', dest='serve', action='store_true', 57 | help='Run a web server for visualizing the ' 58 | 'dependency graph') 59 | parser.add_argument('-b', '--bind-ip', dest='bindaddr', type=str, 60 | metavar='IP', default='127.0.0.1', 61 | help='IP address for webserver to ' 62 | 'bind to [%(default)s]') 63 | parser.add_argument('-p', '--port', dest='port', type=int, metavar='PORT', 64 | default=5000, 65 | help='Port number for webserver [%(default)s]') 66 | parser.add_argument('-r', '--recurse', dest='recurse', action='store_true', 67 | help='Follow dependencies recursively') 68 | parser.add_argument('-e', '--exclude-commits', dest='exclude_commits', 69 | action='append', metavar='COMMITISH', 70 | help='Exclude commits which are ancestors of the ' 71 | 'given COMMITISH (can be repeated)') 72 | parser.add_argument('-c', '--context-lines', dest='context_lines', 73 | type=int, metavar='NUM', default=1, 74 | help='Number of lines of diff context to use ' 75 | '[%(default)s]') 76 | parser.add_argument('-d', '--debug', dest='debug', action='store_true', 77 | help='Show debugging') 78 | parser.add_argument('--pygit2-blame', dest='pygit2_blame', action='store_true', 79 | help="Use pygit2's blame algorithm (slower than git's)") 80 | 81 | options, args = parser.parse_known_args() 82 | 83 | # Are we potentially detecting dependencies for more than one commit? 84 | # Even if we're not recursing, the user could specify multiple commits 85 | # via CLI arguments. 86 | options.multi = options.recurse 87 | 88 | if options.serve: 89 | if options.log: 90 | parser.error('--log does not make sense in webserver mode.') 91 | if options.json: 92 | parser.error('--json does not make sense in webserver mode.') 93 | if options.recurse: 94 | parser.error('--recurse does not make sense in webserver mode.') 95 | if len(args) > 0: 96 | parser.error('Specifying commit-ishs does not make sense in ' 97 | 'webserver mode.') 98 | else: 99 | if len(args) == 0: 100 | parser.error('You must specify at least one commit-ish.') 101 | 102 | return options, args 103 | 104 | 105 | def cli(options, args): 106 | detector = DependencyDetector(options) 107 | 108 | if options.json: 109 | listener = JSONDependencyListener(options) 110 | else: 111 | listener = CLIDependencyListener(options) 112 | 113 | detector.add_listener(listener) 114 | 115 | if len(args) > 1: 116 | options.multi = True 117 | 118 | for revspec in args: 119 | revs = GitUtils.rev_list(revspec) 120 | if len(revs) > 1: 121 | options.multi = True 122 | 123 | for rev in revs: 124 | try: 125 | detector.find_dependencies(rev) 126 | except KeyboardInterrupt: 127 | break 128 | 129 | if options.json: 130 | print(json.dumps(listener.json(), sort_keys=True, indent=4)) 131 | 132 | 133 | def main(args): 134 | options, args = parse_args() 135 | # rev_list = sys.stdin.readlines() 136 | 137 | if options.serve: 138 | serve(options) 139 | else: 140 | sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1) 141 | try: 142 | cli(options, args) 143 | except InvalidCommitish as e: 144 | abort(e.message()) 145 | 146 | 147 | def run(): 148 | main(sys.argv[1:]) 149 | 150 | 151 | if __name__ == "__main__": 152 | run() 153 | -------------------------------------------------------------------------------- /git_deps/detector.py: -------------------------------------------------------------------------------- 1 | import re 2 | import subprocess 3 | 4 | import pygit2 5 | 6 | from git_deps.utils import abort, standard_logger 7 | from git_deps.gitutils import GitUtils 8 | from git_deps.listener.base import DependencyListener 9 | from git_deps.errors import InvalidCommitish 10 | from git_deps.blame import blame_via_subprocess 11 | 12 | 13 | class DependencyDetector(object): 14 | """Class for automatically detecting dependencies between git 15 | commits. A dependency is inferred by diffing the commit with each 16 | of its parents, and for each resulting hunk, performing a blame to 17 | see which commit was responsible for introducing the lines to 18 | which the hunk was applied. 19 | 20 | Dependencies can be traversed recursively, building a dependency 21 | tree represented (conceptually) by a list of edges. 22 | """ 23 | 24 | def __init__(self, options, repo=None, logger=None): 25 | self.options = options 26 | 27 | if logger is None: 28 | self.logger = standard_logger(self.__class__.__name__, 29 | options.debug) 30 | 31 | if repo is None: 32 | self.repo = GitUtils.get_repo() 33 | else: 34 | self.repo = repo 35 | 36 | # Nested dict mapping dependents -> dependencies -> files 37 | # causing that dependency -> numbers of lines within that file 38 | # causing that dependency. The first two levels form edges in 39 | # the dependency graph, and the latter two tell us what caused 40 | # those edges. 41 | self.dependencies = {} 42 | 43 | # A TODO list (queue) and dict of dependencies which haven't 44 | # yet been recursively followed. Only useful when recursing. 45 | self.todo = [] 46 | self.todo_d = {} 47 | 48 | # An ordered list and dict of commits whose dependencies we 49 | # have already detected. 50 | self.done = [] 51 | self.done_d = {} 52 | 53 | # A cache mapping SHA1s to commit objects 54 | self.commits = {} 55 | 56 | # Memoization for branch_contains() 57 | self.branch_contains_cache = {} 58 | 59 | # Callbacks to be invoked when a new dependency has been 60 | # discovered. 61 | self.listeners = [] 62 | 63 | def add_listener(self, listener): 64 | if not isinstance(listener, DependencyListener): 65 | raise RuntimeError("Listener must be a DependencyListener") 66 | self.listeners.append(listener) 67 | listener.set_detector(self) 68 | 69 | def notify_listeners(self, event, *args): 70 | for listener in self.listeners: 71 | fn = getattr(listener, event) 72 | fn(*args) 73 | 74 | def seen_commit(self, rev): 75 | return rev in self.commits 76 | 77 | def get_commit(self, rev): 78 | if rev in self.commits: 79 | return self.commits[rev] 80 | 81 | self.commits[rev] = GitUtils.ref_commit(self.repo, rev) 82 | 83 | return self.commits[rev] 84 | 85 | def find_dependencies(self, dependent_rev, recurse=None): 86 | """Find all dependencies of the given revision, recursively traversing 87 | the dependency tree if requested. 88 | """ 89 | if recurse is None: 90 | recurse = self.options.recurse 91 | 92 | try: 93 | dependent = self.get_commit(dependent_rev) 94 | except InvalidCommitish as e: 95 | abort(e.message()) 96 | 97 | self.todo.append(dependent) 98 | self.todo_d[dependent.hex] = True 99 | 100 | first_time = True 101 | 102 | while self.todo: 103 | sha1s = [commit.hex[:8] for commit in self.todo] 104 | if first_time: 105 | self.logger.info("Initial TODO list: %s" % " ".join(sha1s)) 106 | first_time = False 107 | else: 108 | self.logger.info(" TODO list now: %s" % " ".join(sha1s)) 109 | dependent = self.todo.pop(0) 110 | dependent_sha1 = dependent.hex 111 | del self.todo_d[dependent_sha1] 112 | self.logger.info(" Processing %s from TODO list" % 113 | dependent_sha1[:8]) 114 | 115 | if dependent_sha1 in self.done_d: 116 | self.logger.info(" %s already done previously" % 117 | dependent_sha1) 118 | continue 119 | 120 | self.notify_listeners('new_commit', dependent) 121 | 122 | if dependent.parents: # the root commit does not have parents 123 | parent = dependent.parents[0] 124 | self.find_dependencies_with_parent(dependent, parent) 125 | 126 | self.done.append(dependent_sha1) 127 | self.done_d[dependent_sha1] = True 128 | self.logger.info(" Found all dependencies for %s" % 129 | dependent_sha1[:8]) 130 | # A commit won't have any dependencies if it only added new files 131 | dependencies = self.dependencies.get(dependent_sha1, {}) 132 | self.notify_listeners('dependent_done', dependent, dependencies) 133 | 134 | self.logger.info("Finished processing TODO list") 135 | self.notify_listeners('all_done') 136 | 137 | def find_dependencies_with_parent(self, dependent, parent): 138 | """Find all dependencies of the given revision caused by the 139 | given parent commit. This will be called multiple times for 140 | merge commits which have multiple parents. 141 | """ 142 | self.logger.info(" Finding dependencies of %s via parent %s" % 143 | (dependent.hex[:8], parent.hex[:8])) 144 | diff = self.repo.diff(parent, dependent, 145 | context_lines=self.options.context_lines) 146 | for patch in diff: 147 | path = patch.delta.old_file.path 148 | self.logger.info(" Examining hunks in %s" % path) 149 | for hunk in patch.hunks: 150 | self.blame_diff_hunk(dependent, parent, path, hunk) 151 | 152 | def blame_diff_hunk(self, dependent, parent, path, hunk): 153 | """Run git blame on the parts of the hunk which exist in the 154 | older commit in the diff. The commits generated by git blame 155 | are the commits which the newer commit in the diff depends on, 156 | because without the lines from those commits, the hunk would 157 | not apply correctly. 158 | """ 159 | line_range_before = "-%d,%d" % (hunk.old_start, hunk.old_lines) 160 | line_range_after = "+%d,%d" % (hunk.new_start, hunk.new_lines) 161 | self.logger.info(" Blaming hunk %s @ %s (listed below)" % 162 | (line_range_before, parent.hex[:8])) 163 | 164 | if not self.tree_lookup(path, parent): 165 | # This is probably because dependent added a new directory 166 | # which was not previously in the parent. 167 | return 168 | 169 | blame = self.run_blame(hunk, parent, path) 170 | 171 | dependent_sha1 = dependent.hex 172 | self.register_new_dependent(dependent, dependent_sha1) 173 | 174 | line_to_culprit = {} 175 | 176 | for blame_hunk in blame: 177 | self.process_blame_hunk(dependent, dependent_sha1, parent, 178 | path, blame_hunk, line_to_culprit) 179 | 180 | self.debug_hunk(line_range_before, line_range_after, hunk, 181 | line_to_culprit) 182 | 183 | def process_blame_hunk(self, dependent, dependent_sha1, parent, 184 | path, blame_hunk, line_to_culprit): 185 | 186 | orig_line_num = blame_hunk.orig_start_line_number 187 | line_num = blame_hunk.final_start_line_number 188 | dependency_sha1 = blame_hunk.orig_commit_id.hex 189 | line_representation = f"{dependency_sha1} {orig_line_num} {line_num}" 190 | 191 | self.logger.debug(f" ! {line_representation}") 192 | 193 | dependency = self.get_commit(dependency_sha1) 194 | for i in range(blame_hunk.lines_in_hunk): 195 | line_to_culprit[line_num + i] = dependency.hex 196 | 197 | if self.is_excluded(dependency): 198 | self.logger.debug( 199 | " Excluding dependency %s from line %s (%s)" % 200 | (dependency_sha1[:8], line_num, 201 | GitUtils.oneline(dependency))) 202 | return 203 | 204 | if dependency_sha1 not in self.dependencies[dependent_sha1]: 205 | self.process_new_dependency(dependent, dependent_sha1, 206 | dependency, dependency_sha1, 207 | path, line_num) 208 | 209 | self.record_dependency_source(parent, 210 | dependent, dependent_sha1, 211 | dependency, dependency_sha1, 212 | path, line_num, line_representation) 213 | 214 | def debug_hunk(self, line_range_before, line_range_after, hunk, 215 | line_to_culprit): 216 | diff_format = ' | %8.8s %5s %s%s' 217 | hunk_header = '@@ %s %s @@' % (line_range_before, line_range_after) 218 | self.logger.debug(diff_format % ('--------', '-----', '', hunk_header)) 219 | line_num = hunk.old_start 220 | for line in hunk.lines: 221 | if "\n\\ No newline at end of file" == line.content.rstrip(): 222 | break 223 | if line.origin == '+': 224 | rev = ln = '' 225 | else: 226 | rev = line_to_culprit[line_num] 227 | ln = line_num 228 | line_num += 1 229 | self.logger.debug(diff_format % 230 | (rev, ln, line.origin, line.content.rstrip())) 231 | 232 | def register_new_dependent(self, dependent, dependent_sha1): 233 | if dependent_sha1 not in self.dependencies: 234 | self.logger.info(" New dependent: %s" % 235 | GitUtils.commit_summary(dependent)) 236 | self.dependencies[dependent_sha1] = {} 237 | self.notify_listeners("new_dependent", dependent) 238 | 239 | def run_blame(self, hunk, parent, path): 240 | if self.options.pygit2_blame: 241 | return self.repo.blame(path, 242 | newest_commit=parent.hex, 243 | min_line=hunk.old_start, 244 | max_line=hunk.old_start + hunk.old_lines - 1) 245 | else: 246 | return blame_via_subprocess(path, 247 | parent.hex, 248 | hunk.old_start, 249 | hunk.old_lines) 250 | 251 | def is_excluded(self, commit): 252 | if self.options.exclude_commits is not None: 253 | for exclude in self.options.exclude_commits: 254 | if self.branch_contains(commit, exclude): 255 | return True 256 | return False 257 | 258 | def process_new_dependency(self, dependent, dependent_sha1, 259 | dependency, dependency_sha1, 260 | path, line_num): 261 | if not self.seen_commit(dependency): 262 | self.notify_listeners("new_commit", dependency) 263 | self.dependencies[dependent_sha1][dependency_sha1] = {} 264 | 265 | self.notify_listeners("new_dependency", 266 | dependent, dependency, path, line_num) 267 | 268 | self.logger.info( 269 | " New dependency %s -> %s via line %s (%s)" % 270 | (dependent_sha1[:8], dependency_sha1[:8], line_num, 271 | GitUtils.oneline(dependency))) 272 | 273 | if dependency_sha1 in self.todo_d: 274 | self.logger.info( 275 | " Dependency on %s via line %s already in TODO" 276 | % (dependency_sha1[:8], line_num,)) 277 | return 278 | 279 | if dependency_sha1 in self.done_d: 280 | self.logger.info( 281 | " Dependency on %s via line %s already done" % 282 | (dependency_sha1[:8], line_num,)) 283 | return 284 | 285 | if dependency_sha1 not in self.dependencies: 286 | if self.options.recurse: 287 | self.todo.append(dependency) 288 | self.todo_d[dependency.hex] = True 289 | self.logger.info(" + Added %s to TODO" % 290 | dependency.hex[:8]) 291 | 292 | def record_dependency_source(self, parent, 293 | dependent, dependent_sha1, 294 | dependency, dependency_sha1, 295 | path, line_num, line): 296 | dep_sources = self.dependencies[dependent_sha1][dependency_sha1] 297 | 298 | if path not in dep_sources: 299 | dep_sources[path] = {} 300 | self.notify_listeners('new_path', 301 | dependent, dependency, path, line_num) 302 | 303 | if line_num in dep_sources[path]: 304 | abort("line %d already found when blaming %s:%s\n" 305 | "old:\n %s\n" 306 | "new:\n %s" % 307 | (line_num, parent.hex[:8], path, 308 | dep_sources[path][line_num], line)) 309 | 310 | dep_sources[path][line_num] = line 311 | self.logger.debug(" New line for %s -> %s: %s" % 312 | (dependent_sha1[:8], dependency_sha1[:8], line)) 313 | self.notify_listeners('new_line', 314 | dependent, dependency, path, line_num) 315 | 316 | def branch_contains(self, commit, branch): 317 | sha1 = commit.hex 318 | branch_commit = self.get_commit(branch) 319 | branch_sha1 = branch_commit.hex 320 | self.logger.debug(" Does %s (%s) contain %s?" % 321 | (branch, branch_sha1[:8], sha1[:8])) 322 | 323 | if sha1 not in self.branch_contains_cache: 324 | self.branch_contains_cache[sha1] = {} 325 | if branch_sha1 in self.branch_contains_cache[sha1]: 326 | memoized = self.branch_contains_cache[sha1][branch_sha1] 327 | self.logger.debug(" %s (memoized)" % memoized) 328 | return memoized 329 | 330 | cmd = ['git', 'merge-base', sha1, branch_sha1] 331 | # self.logger.debug(" ".join(cmd)) 332 | out = subprocess.check_output(cmd, universal_newlines=True).strip() 333 | self.logger.debug(" merge-base returned: %s" % out[:8]) 334 | result = out == sha1 335 | self.logger.debug(" %s" % result) 336 | self.branch_contains_cache[sha1][branch_sha1] = result 337 | return result 338 | 339 | def tree_lookup(self, target_path, commit): 340 | """Navigate to the tree or blob object pointed to by the given target 341 | path for the given commit. This is necessary because each git 342 | tree only contains entries for the directory it refers to, not 343 | recursively for all subdirectories. 344 | """ 345 | segments = target_path.split("/") 346 | tree_or_blob = commit.tree 347 | path = '' 348 | while segments: 349 | dirent = segments.pop(0) 350 | if isinstance(tree_or_blob, pygit2.Tree): 351 | if dirent in tree_or_blob: 352 | tree_or_blob = self.repo[tree_or_blob[dirent].oid] 353 | # self.logger.debug(" %s in %s" % (dirent, path)) 354 | if path: 355 | path += '/' 356 | path += dirent 357 | else: 358 | # This is probably because we were called on a 359 | # commit whose parent added a new directory. 360 | self.logger.debug(" %s not in %s in %s" % 361 | (dirent, path, commit.hex[:8])) 362 | return None 363 | else: 364 | self.logger.debug(" %s not a tree in %s" % 365 | (tree_or_blob, commit.hex[:8])) 366 | return None 367 | return tree_or_blob 368 | 369 | def edges(self): 370 | return [ 371 | [(dependent, dependency) 372 | for dependency in self.dependencies[dependent]] 373 | for dependent in self.dependencies.keys() 374 | ] 375 | -------------------------------------------------------------------------------- /git_deps/errors.py: -------------------------------------------------------------------------------- 1 | class InvalidCommitish(Exception): 2 | def __init__(self, commitish): 3 | self.commitish = commitish 4 | 5 | def message(self): 6 | return "Couldn't resolve commitish %s" % self.commitish 7 | -------------------------------------------------------------------------------- /git_deps/gitutils.py: -------------------------------------------------------------------------------- 1 | import pygit2 2 | import re 3 | import subprocess 4 | 5 | from git_deps.errors import InvalidCommitish 6 | from git_deps.utils import abort 7 | 8 | 9 | class GitUtils(object): 10 | @classmethod 11 | def abbreviate_sha1(cls, sha1): 12 | """Uniquely abbreviates the given SHA1.""" 13 | 14 | # For now we invoke git-rev-parse(1), but hopefully eventually 15 | # we will be able to do this via pygit2. 16 | cmd = ['git', 'rev-parse', '--short', sha1] 17 | # cls.logger.debug(" ".join(cmd)) 18 | out = subprocess.check_output(cmd, universal_newlines=True).strip() 19 | # cls.logger.debug(out) 20 | return out 21 | 22 | @classmethod 23 | def describe(cls, sha1): 24 | """Returns a human-readable representation of the given SHA1.""" 25 | 26 | # For now we invoke git-describe(1), but eventually we will be 27 | # able to do this via pygit2, since libgit2 already provides 28 | # an API for this: 29 | # https://github.com/libgit2/pygit2/pull/459#issuecomment-68866929 30 | # https://github.com/libgit2/libgit2/pull/2592 31 | cmd = [ 32 | 'git', 'describe', 33 | '--all', # look for tags and branches 34 | '--long', # remotes/github/master-0-g2b6d591 35 | # '--contains', 36 | # '--abbrev', 37 | sha1 38 | ] 39 | # cls.logger.debug(" ".join(cmd)) 40 | out = None 41 | try: 42 | out = subprocess.check_output( 43 | cmd, stderr=subprocess.STDOUT, universal_newlines=True) 44 | except subprocess.CalledProcessError as e: 45 | if e.output.find('No tags can describe') != -1: 46 | return '' 47 | raise 48 | 49 | out = out.strip() 50 | out = re.sub(r'^(heads|tags|remotes)/', '', out) 51 | # We already have the abbreviated SHA1 from abbreviate_sha1() 52 | out = re.sub(r'-g[0-9a-f]{7,}$', '', out) 53 | # cls.logger.debug(out) 54 | return out 55 | 56 | @classmethod 57 | def oneline(cls, commit): 58 | try: 59 | ret = commit.message.split('\n', 1)[0] 60 | except UnicodeDecodeError: 61 | ret = "Invalid utf-8 commit message" 62 | return ret 63 | 64 | @classmethod 65 | def commit_summary(cls, commit): 66 | return "%s %s" % (commit.hex[:8], cls.oneline(commit)) 67 | 68 | @classmethod 69 | def refs_to(cls, sha1, repo): 70 | """Returns all refs pointing to the given SHA1.""" 71 | matching = [] 72 | for refname in repo.listall_references(): 73 | symref = repo.lookup_reference(refname) 74 | dref = symref.resolve() 75 | oid = dref.target 76 | commit = repo.get(oid) 77 | if commit.hex == sha1: 78 | matching.append(symref.shorthand) 79 | 80 | return matching 81 | 82 | @classmethod 83 | def rev_list(cls, rev_range): 84 | cmd = ['git', 'rev-list', rev_range] 85 | return subprocess.check_output(cmd, universal_newlines=True) \ 86 | .strip().split('\n') 87 | 88 | @classmethod 89 | def ref_commit(cls, repo, rev): 90 | try: 91 | commit = repo.revparse_single(rev) 92 | except (KeyError, ValueError): 93 | raise InvalidCommitish(rev) 94 | 95 | if isinstance(commit, pygit2.Tag): 96 | commit = commit.get_object() 97 | 98 | return commit 99 | 100 | @classmethod 101 | def get_repo(cls, path='.'): 102 | try: 103 | repo_path = pygit2.discover_repository(path) 104 | except KeyError: 105 | abort("Couldn't find a repository in the current directory.") 106 | 107 | return pygit2.Repository(repo_path) 108 | -------------------------------------------------------------------------------- /git_deps/handler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | from __future__ import print_function 4 | 5 | import logging 6 | import logging.handlers 7 | import os 8 | import re 9 | import subprocess 10 | import sys 11 | import urllib 12 | 13 | try: 14 | from urllib.parse import urlparse # Python 3 15 | except ImportError: 16 | from urlparse import urlparse # Python 2 17 | 18 | from git_deps.utils import abort 19 | 20 | 21 | def usage(): 22 | abort("usage: git-handler URL") 23 | 24 | 25 | def get_logger(): 26 | logger = logging.getLogger('foo') 27 | # logger.setLevel(logging.DEBUG) 28 | 29 | slh = logging.handlers.SysLogHandler(address='/dev/log') 30 | slf = logging.Formatter('gitfile-handler: %(message)s') 31 | slh.setFormatter(slf) 32 | logger.addHandler(slh) 33 | logger.addHandler(logging.StreamHandler()) 34 | 35 | return logger 36 | 37 | 38 | def main(args): 39 | if len(args) != 1: 40 | usage() 41 | 42 | logger = get_logger() 43 | 44 | url = args[0] 45 | logger.debug("received URL: %s" % url) 46 | if re.search(r'%23', url): 47 | # Uh-oh, double-encoded URIs! Some versions of Chrome 48 | # encode the value you set location.href too. 49 | url = urllib.unquote(url) 50 | logger.debug("unquoted: %s" % url) 51 | url = urlparse(url) 52 | logger.debug("parsed: %r" % repr(url)) 53 | 54 | if url.scheme != 'gitfile': 55 | abort("URL must use gitfile:// scheme") 56 | 57 | repo = os.path.join(url.netloc, url.path) 58 | rev = url.fragment 59 | os.chdir(repo) 60 | 61 | subprocess.Popen(['gitk', '--all', '--select-commit=%s' % rev]) 62 | 63 | 64 | def run(): 65 | main(sys.argv[1:]) 66 | 67 | 68 | if __name__ == "__main__": 69 | run() 70 | -------------------------------------------------------------------------------- /git_deps/html/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | bower_components/ 3 | -------------------------------------------------------------------------------- /git_deps/html/css/git-deps-tips.css: -------------------------------------------------------------------------------- 1 | .d3-tip { 2 | line-height: 1; 3 | padding: 5px; 4 | background: rgba(247, 251, 252, 0.9); 5 | font-family: Helvetica, arial, freesans, clean, sans-serif, 'Segoe UI Emoji', 'Segoe UI Symbol'; 6 | border-radius: 2px; 7 | pointer-events: none; 8 | border: 1px solid #e5e5e5; 9 | } 10 | 11 | /* Creates a small triangle extender for the tooltip */ 12 | .d3-tip:after { 13 | box-sizing: border-box; 14 | display: inline; 15 | font-size: 10px; 16 | width: 100%; 17 | line-height: 1; 18 | color: rgba(0, 0, 0, 0.8); 19 | position: absolute; 20 | pointer-events: none; 21 | } 22 | 23 | /* Northward tooltips */ 24 | .d3-tip.n:after { 25 | content: "\25BC"; 26 | margin: -1px 0 0 0; 27 | top: 100%; 28 | left: 0; 29 | text-align: center; 30 | } 31 | 32 | /* Eastward tooltips */ 33 | .d3-tip.e:after { 34 | content: "\25C0"; 35 | margin: -4px 0 0 0; 36 | top: 50%; 37 | left: -8px; 38 | } 39 | 40 | /* Southward tooltips */ 41 | .d3-tip.s:after { 42 | content: "\25B2"; 43 | margin: 0 0 1px 0; 44 | top: -8px; 45 | left: 0; 46 | text-align: center; 47 | } 48 | 49 | /* Westward tooltips */ 50 | .d3-tip.w:after { 51 | content: "\25B6"; 52 | margin: -4px 0 0 -1px; 53 | top: 50%; 54 | left: 100%; 55 | } 56 | 57 | .d3-tip p.commit-title { 58 | font-weight: bold; 59 | color: #4e575b; 60 | font-size: 15px; 61 | margin: 0.5em 0; 62 | } 63 | 64 | .d3-tip .commit-describe { 65 | font-size: 12px; 66 | margin: 0.5em 0; 67 | } 68 | 69 | .d3-tip .commit-meta { 70 | color: #979a9c; 71 | font-size: 11px; 72 | } 73 | 74 | .d3-tip .commit-body pre { 75 | color: #596063; 76 | margin: 0.5em 0; 77 | /* padding-left: 8px; */ 78 | /* border-left: 1px solid #e5e5e5; */ 79 | } 80 | -------------------------------------------------------------------------------- /git_deps/html/css/git-deps.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Helvetica, arial, freesans, clean, sans-serif, 'Segoe UI Emoji', 'Segoe UI Symbol'; 3 | width: 100vw; 4 | height: 100vh; 5 | margin: 0px; 6 | } 7 | 8 | #page { 9 | margin: 8px; 10 | display: flex; /* use the flex model */ 11 | flex-direction: column; 12 | position: absolute; 13 | top: 0; 14 | bottom: 0; 15 | left: 0; 16 | right: 0; 17 | } 18 | 19 | h1 { 20 | margin: 0; 21 | background: rgb(128, 150, 174); 22 | color: white; 23 | padding: 0.3em; 24 | } 25 | 26 | #top p { 27 | margin-right: 320px; /* Avoid overlap with noty boxes */ 28 | } 29 | 30 | #svg-container { 31 | flex: 1; 32 | border: 1px solid #ccc; /* width has to be half of SVG_MARGIN */ 33 | } 34 | 35 | rect.background { 36 | fill: white; 37 | cursor: all-scroll; 38 | } 39 | 40 | g.node rect { 41 | stroke: #e5e5e5; 42 | stroke-width: 2px; 43 | cursor: pointer; /* move is semantically better but looks the same as all-scroll */ 44 | } 45 | 46 | g.node rect.explored { 47 | fill: rgba(206, 236, 221, 0.54); 48 | } 49 | 50 | g.node rect.unexplored { 51 | fill: rgba(242, 242, 255, 0.54); 52 | } 53 | 54 | g.node text { 55 | /* fill: black; */ 56 | fill: #295b8c; 57 | font-size: 15px; 58 | font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace; 59 | font-weight: bold; 60 | text-anchor: middle; 61 | alignment-baseline: middle; 62 | cursor: pointer; 63 | pointer-events: none; 64 | } 65 | 66 | .plus-icon use { 67 | display: none; 68 | } 69 | 70 | .plus-icon:hover use { 71 | display: visible; 72 | } 73 | 74 | .link { 75 | fill: none; 76 | stroke-width: 2px; 77 | opacity: 0.4; 78 | marker-end: url(#end-arrow); 79 | } 80 | 81 | .commitish input { 82 | font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace; 83 | } 84 | 85 | .commit-ref { 86 | font-weight: bold; 87 | color: #26894d; 88 | } 89 | 90 | .noty_text p { 91 | margin-top: 6px; 92 | margin-bottom: 6px; 93 | } 94 | -------------------------------------------------------------------------------- /git_deps/html/git-deps.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | git commit dependency graph 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |

git commit dependency graph

20 | 21 |

22 | Use mouse-wheel to zoom. 23 | Drag background to pan. 24 | Hover over a commit for more information. 25 | Click a commit's plus icon to find dependencies of that commit. 26 |

27 | 28 |
29 | Detect dependencies for: 30 | 32 | 33 | 34 | 35 |
36 |
37 | 38 |
39 |
40 | 41 | 42 | -------------------------------------------------------------------------------- /git_deps/html/js/.gitignore: -------------------------------------------------------------------------------- 1 | bundle.js 2 | -------------------------------------------------------------------------------- /git_deps/html/js/fullscreen.js: -------------------------------------------------------------------------------- 1 | 2 | function endFullScreen(oncancel) { 3 | if (!RunPrefixMethod(document, "FullScreen") && !RunPrefixMethod(document, "IsFullScreen")) { 4 | oncancel(); 5 | } 6 | } 7 | function fullScreen(e, oncancel) { 8 | if (RunPrefixMethod(document, "FullScreen") || RunPrefixMethod(document, "IsFullScreen")) { 9 | RunPrefixMethod(document, "CancelFullScreen"); 10 | } 11 | else { 12 | RunPrefixMethod(e, "RequestFullScreen"); 13 | e.setAttribute("width", screen.width); 14 | e.setAttribute("height", screen.height); 15 | } 16 | if (arguments.length > 1) { 17 | var f = function () { endFullScreen(oncancel); }; 18 | document.addEventListener("fullscreenchange", f, false); 19 | document.addEventListener("mozfullscreenchange", f, false); 20 | document.addEventListener("webkitfullscreenchange", f, false); 21 | } 22 | } 23 | 24 | var pfx = ["webkit", "moz", "ms", "o", ""]; 25 | function RunPrefixMethod(obj, method) { 26 | 27 | var p = 0, m, t; 28 | while (p < pfx.length && !obj[m]) { 29 | m = method; 30 | if (pfx[p] == "") { 31 | m = m.substr(0, 1).toLowerCase() + m.substr(1); 32 | } 33 | m = pfx[p] + m; 34 | t = typeof obj[m]; 35 | if (t != "undefined") { 36 | pfx = [pfx[p]]; 37 | return (t == "function" ? obj[m]() : obj[m]); 38 | } 39 | p++; 40 | } 41 | } 42 | 43 | function isFullScreen() { 44 | var fullscreenEnabled = document.fullscreenEnabled || document.mozFullScreenEnabled || document.webkitFullscreenEnabled; 45 | return fullscreenEnabled; 46 | } 47 | 48 | module.exports = fullScreen; 49 | -------------------------------------------------------------------------------- /git_deps/html/js/git-deps-data.coffee: -------------------------------------------------------------------------------- 1 | # The list of nodes and links to feed into WebCola. 2 | # These will be dynamically built as we retrieve them via XHR. 3 | nodes = [] 4 | links = [] 5 | 6 | # WebCola requires links to refer to nodes by index within the 7 | # nodes array, so as nodes are dynamically added, we need to 8 | # be able to retrieve their index efficiently in order to add 9 | # links to/from them. This also allows us to avoid adding the 10 | # same node twice. 11 | node_index = {} 12 | 13 | # Track dependencies in a hash of hashes which maps parents to 14 | # children to booleans. Constraints will be added to try to keep 15 | # siblings at the same y position. For this we need to track 16 | # siblings, which we do by mapping each parent to an array of its 17 | # siblings in this hash. It also enables us to deduplicate links 18 | # across multiple XHRs. 19 | deps = {} 20 | 21 | # Track dependences in reverse in a hash of hashes which maps children 22 | # to parents to booleans. This allows us to highlight parents when 23 | # the mouse hovers over a child, and know when we can safely remove 24 | # a commit due to its sole parent being deleted. 25 | rdeps = {} 26 | 27 | # Returns 1 iff a node was added, otherwise 0. 28 | add_node = (commit) -> 29 | if commit.sha1 of node_index 30 | n = node commit.sha1 31 | n.explored ||= commit.explored 32 | return 0 33 | 34 | nodes.push commit 35 | node_index[commit.sha1] = nodes.length - 1 36 | return 1 37 | 38 | # Returns 1 iff a dependency was added, otherwise 0. 39 | add_dependency = (parent_sha1, child_sha1) -> 40 | deps[parent_sha1] = {} unless parent_sha1 of deps 41 | 42 | # We've already got this link, presumably 43 | # from a previous XHR. 44 | return 0 if child_sha1 of deps[parent_sha1] 45 | deps[parent_sha1][child_sha1] = true 46 | add_link parent_sha1, child_sha1 47 | return 1 48 | 49 | # Returns 1 iff a reverse dependency was added, otherwise 0. 50 | add_rev_dependency = (child_sha1, parent_sha1) -> 51 | rdeps[child_sha1] = {} unless child_sha1 of rdeps 52 | 53 | # We've already got this link, presumably 54 | # from a previous XHR. 55 | return 0 if parent_sha1 of rdeps[child_sha1] 56 | rdeps[child_sha1][parent_sha1] = true 57 | return 1 58 | 59 | add_link = (parent_sha1, child_sha1) -> 60 | pi = node_index[parent_sha1] 61 | ci = node_index[child_sha1] 62 | link = 63 | source: pi 64 | target: ci 65 | value: 1 # no idea what WebCola needs this for 66 | 67 | links.push link 68 | return 69 | 70 | # Returns true iff new data was added. 71 | add_data = (data) -> 72 | new_nodes = 0 73 | new_deps = 0 74 | for commit in data.commits 75 | new_nodes += add_node(commit) 76 | 77 | for dep in data.dependencies 78 | new_deps += add_dependency(dep.parent, dep.child) 79 | add_rev_dependency(dep.child, dep.parent) 80 | 81 | if new_nodes > 0 or new_deps > 0 82 | return [ 83 | new_nodes 84 | new_deps 85 | data.query 86 | ] 87 | 88 | return false 89 | 90 | node = (sha1) -> 91 | i = node_index[sha1] 92 | unless i? 93 | console.error "No index for SHA1 '#{sha1}'" 94 | return null 95 | return nodes[i] 96 | 97 | module.exports = 98 | # Variables (N.B. if these variables are reinitialised at any 99 | # point, the values here will become stale and require updating) 100 | nodes: nodes 101 | links: links 102 | node_index: node_index 103 | deps: deps 104 | rdeps: rdeps 105 | 106 | # Functions 107 | add: add_data 108 | node: node 109 | -------------------------------------------------------------------------------- /git_deps/html/js/git-deps-graph.coffee: -------------------------------------------------------------------------------- 1 | jQuery = require "jquery" 2 | $ = jQuery 3 | d3 = require "d3" 4 | d3tip = require "d3-tip" 5 | d3tip d3 6 | 7 | # Hacky workaround: 8 | # https://github.com/tgdwyer/WebCola/issues/145#issuecomment-271316856 9 | window.d3 = d3 10 | 11 | cola = require "webcola" 12 | 13 | global.gdn = require "./git-deps-noty.coffee" 14 | global.gdd = require "./git-deps-data.coffee" 15 | global.gdl = require "./git-deps-layout.coffee" 16 | 17 | fullScreen = require "./fullscreen" 18 | 19 | SVG_MARGIN = 2 # space around , matching #svg-container border 20 | RECT_MARGIN = 14 # space in between 21 | PADDING = 5 # space in between label and border 22 | EDGE_ROUTING_MARGIN = 3 23 | PLUS_ICON_WIDTH = 14 24 | 25 | svg_width = 960 26 | svg_height = 800 27 | old_svg_height = undefined 28 | old_svg_width = undefined 29 | 30 | color = d3.scale.category20() 31 | 32 | global.d3cola = cola.d3adaptor() 33 | d3cola 34 | .flowLayout("y", 100) 35 | .avoidOverlaps(true) 36 | #.linkDistance(60) 37 | #.symmetricDiffLinkLengths(30) 38 | #.jaccardLinkLengths(100) 39 | 40 | # d3 visualization elements 41 | container = undefined 42 | svg = undefined 43 | fg = undefined 44 | nodes = undefined 45 | paths = undefined 46 | tip = undefined 47 | tip_template = undefined 48 | zoom = undefined 49 | 50 | options = undefined # Options will be retrieved from web server 51 | 52 | jQuery -> 53 | d3.json "options", (error, data) -> 54 | options = data 55 | gdl.debug = options.debug 56 | 57 | d3.html "tip-template.html", (error, html) -> 58 | tip_template = html 59 | 60 | #setup_default_form_values(); 61 | $("form.commitish").submit (event) -> 62 | event.preventDefault() 63 | add_commitish $(".commitish input").val() 64 | 65 | init_svg() 66 | 67 | setup_default_form_values = -> 68 | $("input[type=text]").each(-> 69 | $(this).val $(this).attr("defaultValue") 70 | $(this).css color: "grey" 71 | ).focus(-> 72 | if $(this).val() is $(this).attr("defaultValue") 73 | $(this).val "" 74 | $(this).css color: "black" 75 | ).blur -> 76 | if $(this).val() is "" 77 | $(this).val $(this).attr("defaultValue") 78 | $(this).css color: "grey" 79 | 80 | resize_window = -> 81 | calculate_svg_size_from_container() 82 | fit_svg_to_container() 83 | redraw true 84 | 85 | redraw = (transition) -> 86 | # if mouse down then we are dragging not panning 87 | # if nodeMouseDown 88 | # return 89 | ((if transition then fg.transition() else fg)) 90 | .attr "transform", 91 | "translate(#{zoom.translate()}) scale(#{zoom.scale()})" 92 | 93 | graph_bounds = -> 94 | x = Number.POSITIVE_INFINITY 95 | X = Number.NEGATIVE_INFINITY 96 | y = Number.POSITIVE_INFINITY 97 | Y = Number.NEGATIVE_INFINITY 98 | fg.selectAll(".node").each (d) -> 99 | x = Math.min(x, d.x - d.width / 2) 100 | y = Math.min(y, d.y - d.height / 2) 101 | X = Math.max(X, d.x + d.width / 2) 102 | Y = Math.max(Y, d.y + d.height / 2) 103 | return {} = 104 | x: x 105 | X: X 106 | y: y 107 | Y: Y 108 | 109 | fit_svg_to_container = -> 110 | svg.attr("width", svg_width).attr("height", svg_height) 111 | 112 | full_screen_cancel = -> 113 | svg_width = old_svg_width 114 | svg_height = old_svg_height 115 | fit_svg_to_container() 116 | #zoom_to_fit(); 117 | resize_window() 118 | 119 | full_screen_click = -> 120 | fullScreen container.node(), full_screen_cancel 121 | fit_svg_to_container() 122 | resize_window() 123 | #zoom_to_fit(); 124 | return false 125 | 126 | zoom_to_fit = -> 127 | b = graph_bounds() 128 | w = b.X - b.x 129 | h = b.Y - b.y 130 | cw = svg.attr("width") 131 | ch = svg.attr("height") 132 | s = Math.min(cw / w, ch / h) 133 | tx = -b.x * s + (cw / s - w) * s / 2 134 | ty = -b.y * s + (ch / s - h) * s / 2 135 | zoom.translate([tx, ty]).scale s 136 | redraw true 137 | return false 138 | 139 | window.full_screen_click = full_screen_click 140 | window.zoom_to_fit = zoom_to_fit 141 | 142 | add_commitish = (commitish) -> 143 | tip.hide() if tip? 144 | draw_graph commitish 145 | 146 | calculate_svg_size_from_container = -> 147 | old_svg_width = svg_width 148 | old_svg_height = svg_height 149 | svg_width = container.node().offsetWidth - SVG_MARGIN 150 | svg_height = container.node().offsetHeight - SVG_MARGIN 151 | 152 | init_svg = -> 153 | container = d3.select("#svg-container") 154 | calculate_svg_size_from_container() 155 | svg = container.append("svg") 156 | .attr("width", svg_width) 157 | .attr("height", svg_height) 158 | d3cola.size [svg_width, svg_height] 159 | 160 | d3.select(window).on "resize", resize_window 161 | 162 | zoom = d3.behavior.zoom() 163 | 164 | svg.append("rect") 165 | .attr("class", "background") 166 | .attr("width", "100%") 167 | .attr("height", "100%") 168 | .call(zoom.on("zoom", redraw)) 169 | .on("dblclick.zoom", zoom_to_fit) 170 | 171 | fg = svg.append("g") 172 | svg_defs fg 173 | 174 | update_cola = -> 175 | d3cola 176 | .nodes(gdd.nodes) 177 | .links(gdd.links) 178 | .constraints(gdl.constraints) 179 | 180 | draw_graph = (commitish) -> 181 | d3.json "deps.json/" + commitish, (error, data) -> 182 | if error 183 | details = JSON.parse(error.responseText) 184 | gdn.error details.message 185 | return 186 | 187 | new_data = gdd.add(data) 188 | 189 | unless new_data 190 | gdn.warn "No new commits or dependencies found!" 191 | update_rect_explored() 192 | return 193 | new_data_notification new_data 194 | focus_commitish_input() 195 | 196 | gdl.build_constraints() 197 | update_cola() 198 | 199 | paths = fg.selectAll(".link") 200 | .data(gdd.links, link_key) 201 | paths.enter().append("svg:path") 202 | .attr("class", "link") 203 | .attr("stroke", (d) -> color(link_key(d))) 204 | nodes = fg.selectAll(".node") 205 | .data(gdd.nodes, (d) -> d.sha1) 206 | global.nodes = nodes 207 | 208 | g_enter = nodes.enter().append("g") 209 | .attr("class", "node") 210 | # Questionable attempt to use dagre layout as starting positions 211 | # https://github.com/tgdwyer/WebCola/issues/63 212 | nodes.each (d, i) -> 213 | n = gdl.node d.sha1 214 | d.x = n.x 215 | d.y = n.y 216 | nodes.attr "transform", (d) -> 217 | translate d.x, d.y 218 | 219 | # N.B. has to be done on the update selection, i.e. *after* the enter! 220 | nodes.call(d3cola.drag) 221 | 222 | init_tip() unless tip? 223 | # Event handlers need to be updated every time new nodes are added. 224 | init_tip_event_handlers(nodes) 225 | 226 | [rects, labels] = draw_new_nodes fg, g_enter 227 | position_nodes(rects, labels) 228 | update_rect_explored() 229 | 230 | focus_commitish_input = () -> 231 | d3.select('.commitish input').node().focus() 232 | 233 | # Required for object constancy: http://bost.ocks.org/mike/constancy/ ... 234 | link_key = (link) -> 235 | source = sha1_of_link_pointer(link.source) 236 | target = sha1_of_link_pointer(link.target) 237 | return source + " " + target 238 | 239 | # ... but even though link sources and targets are initially fed in 240 | # as indices into the nodes array, webcola then replaces the indices 241 | # with references to the node objects. So we have to deal with both 242 | # cases when ensuring we are uniquely identifying each link. 243 | sha1_of_link_pointer = (pointer) -> 244 | return pointer.sha1 if typeof (pointer) is "object" 245 | return gdd.nodes[pointer].sha1 246 | 247 | init_tip = () -> 248 | tip = d3.tip().attr("class", "d3-tip").html(tip_html) 249 | global.tip = tip 250 | fg.call tip 251 | 252 | # A wrapper around tip.show is required to perform multiple visual 253 | # actions when the mouse hovers over a node; however even if the only 254 | # action required was to show the tool tip, the wrapper would still be 255 | # required in order to work around something which looks like a bug in 256 | # d3 or d3-tip. tip.show is defined as: 257 | # 258 | # function() { 259 | # var args = Array.prototype.slice.call(arguments) 260 | # if(args[args.length - 1] instanceof SVGElement) target = args.pop() 261 | # ... 262 | # 263 | # and there's also: 264 | # 265 | # function getScreenBBox() { 266 | # var targetel = target || d3.event.target; 267 | # ... 268 | # 269 | # which I'm guessing normally uses d3.event.target. However for some 270 | # reason when using tip.show as the dragend handler, d3.event.target 271 | # points to a function rather than the expected DOM element, which 272 | # appears to be exactly the same problem described here: 273 | # 274 | # http://stackoverflow.com/questions/12934731/d3-event-targets 275 | # 276 | # However I tried rects.call ... instead of nodes.call as suggested in 277 | # that SO article, but it resulted in the callback not being triggered 278 | # at all. By *always* providing the exact SVGElement the tip is 279 | # supposed to target, the desired behaviour is obtained. If 280 | # node_mouseover is only used in tip_dragend_handler then the target 281 | # gets memoised, and a normal hover-based tip.show shows the target 282 | # last shown by a drag, rather than the node being hovered over. 283 | # Weird, and annoying. 284 | node_mouseover = (d, i) -> 285 | tip.show d, i, nodes[0][i] 286 | highlight_nodes d3.select(nodes[0][i]), false 287 | highlight_parents(d, i, true) 288 | highlight_children(d, i, true) 289 | 290 | node_mouseout = (d, i) -> 291 | tip.hide d, i, nodes[0][i] 292 | highlight_nodes d3.select(nodes[0][i]), false 293 | highlight_parents(d, i, false) 294 | highlight_children(d, i, false) 295 | 296 | highlight_parents = (d, i, highlight) -> 297 | sha1 = gdd.nodes[i].sha1 298 | parents = nodes.filter (d, i) -> 299 | d.sha1 of (gdd.rdeps[sha1] || {}) 300 | highlight_nodes parents, highlight, 'rgb(74, 200, 148)' 301 | 302 | highlight_children = (d, i, highlight) -> 303 | sha1 = gdd.nodes[i].sha1 304 | children = nodes.filter (d, i) -> 305 | d.sha1 of (gdd.deps[sha1] || {}) 306 | highlight_nodes children, highlight, 'rgb(128, 197, 247)' 307 | 308 | highlight_nodes = (selection, highlight, colour='#c0c0c0') -> 309 | selection.selectAll('rect') 310 | .transition() 311 | .ease('cubic-out') 312 | .duration(200) 313 | .style('stroke', if highlight then colour else '#e5e5e5') 314 | .style('stroke-width', if highlight then '4px' else '2px') 315 | 316 | tip_dragend_handler = (d, i, elt) -> 317 | focus_commitish_input() 318 | node_mouseover d, i 319 | 320 | init_tip_event_handlers = (selection) -> 321 | # We have to reuse the same drag object, otherwise only one 322 | # of the event handlers will work. 323 | drag = d3cola.drag() 324 | hide_tip_on_drag = drag.on("drag", tip.hide) 325 | on_dragend = drag.on("dragend", tip_dragend_handler) 326 | selection.call hide_tip_on_drag 327 | selection.call on_dragend 328 | 329 | draw_new_nodes = (fg, g_enter) -> 330 | rects = g_enter.append('rect') 331 | .attr('rx', 5) 332 | .attr('ry', 5) 333 | .on('dblclick', (d) -> launch_viewer d) 334 | 335 | labels = g_enter.append('text').text((d) -> 336 | d.name 337 | ).each((d) -> 338 | b = @getBBox() 339 | 340 | # Calculate width/height of rectangle from text bounding box. 341 | d.rect_width = b.width + 2 * PADDING 342 | d.rect_height = b.height + 2 * PADDING 343 | 344 | # Now set the node width/height as used by cola for 345 | # positioning. This has to include the margin 346 | # outside the rectangle. 347 | d.width = d.rect_width + 2 * RECT_MARGIN 348 | d.height = d.rect_height + 2 * RECT_MARGIN 349 | ) 350 | 351 | return [rects, labels] 352 | 353 | explore_node = (d) -> 354 | if d.explored 355 | gdn.warn "Commit #{d.name} already explored" 356 | else 357 | add_commitish d.sha1 358 | 359 | launch_viewer = (d) -> 360 | window.location.assign "gitfile://#{options.repo_path}##{d.sha1}" 361 | 362 | new_data_notification = (new_data) -> 363 | new_nodes = new_data[0] 364 | new_deps = new_data[1] 365 | query = new_data[2] 366 | notification = 367 | if query.revspec == query.tip_sha1 368 | "Analysed dependencies of #{query.revspec}" 369 | else if query.revisions.length == 1 370 | "#{query.revspec} 371 | resolved as #{query.tip_abbrev}" 372 | else 373 | "#{query.revspec} 374 | expanded; tip is #{query.tip_abbrev}" 375 | notification += "

#{new_nodes} new commit" 376 | notification += "s" unless new_nodes == 1 377 | notification += "; #{new_deps} new " + 378 | (if new_deps == 1 then "dependency" else "dependencies") 379 | notification += "

" 380 | 381 | gdn.success notification 382 | 383 | svg_defs = () -> 384 | # define arrow markers for graph links 385 | defs = svg.insert("svg:defs") 386 | 387 | defs.append("svg:marker") 388 | .attr("id", "end-arrow") 389 | .attr("viewBox", "0 -5 10 10") 390 | .attr("refX", 6) 391 | .attr("markerWidth", 6) 392 | .attr("markerHeight", 6) 393 | .attr("orient", "auto") 394 | .append("svg:path") 395 | .attr("d", "M0,-5L10,0L0,5") 396 | .attr("fill", "#000") 397 | 398 | plus_icon = defs.append("svg:symbol") 399 | .attr("id", "plus-icon") 400 | .attr("viewBox", "-51 -51 102 102") # allow for stroke-width 1 401 | # border 402 | plus_icon.append("svg:rect") 403 | .attr("width", 100) 404 | .attr("height", 100) 405 | .attr("fill", "#295b8c") 406 | .attr("stroke", "rgb(106, 136, 200)") 407 | .attr("x", -50) 408 | .attr("y", -50) 409 | .attr("rx", 20) 410 | .attr("ry", 20) 411 | # plus sign 412 | plus_icon.append("svg:path") 413 | .attr("d", "M-30,0 H30 M0,-30 V30") 414 | .attr("stroke", "white") 415 | .attr("stroke-width", 10) 416 | .attr("stroke-linecap", "round") 417 | 418 | # Uncomment to see a large version: 419 | # fg.append("use") 420 | # .attr("class", "plus-icon") 421 | # .attr("xlink:href", "#plus-icon") 422 | # .attr("width", "200") 423 | # .attr("height", "200") 424 | # .attr("x", 400) 425 | # .attr("y", 200) 426 | 427 | position_nodes = (rects, labels) -> 428 | rects 429 | .attr("width", (d, i) -> d.rect_width) 430 | .attr("height", (d, i) -> d.rect_height) 431 | .on("mouseover", node_mouseover) 432 | .on("mouseout", node_mouseout) 433 | 434 | # Centre labels 435 | labels 436 | .attr("x", (d) -> d.rect_width / 2) 437 | .attr("y", (d) -> d.rect_height / 2) 438 | .on("mouseover", node_mouseover) 439 | .on("mouseout", node_mouseout) 440 | 441 | d3cola.start 10, 20, 20 442 | d3cola.on "tick", tick_handler 443 | 444 | # d3cola.on "end", routeEdges 445 | 446 | # turn on overlap avoidance after first convergence 447 | # d3cola.on("end", () -> 448 | # unless d3cola.avoidOverlaps 449 | # gdd.nodes.forEach((v) -> 450 | # v.width = v.height = 10 451 | # d3cola.avoidOverlaps true 452 | # d3cola.start 453 | 454 | update_rect_explored = () -> 455 | d3.selectAll(".node rect").attr "class", (d) -> 456 | if d.explored then "explored" else "unexplored" 457 | nodes.each (d) -> 458 | existing_icon = d3.select(this).select("use.plus-icon") 459 | if d.explored 460 | existing_icon.remove() 461 | else if existing_icon.empty() 462 | add_plus_icon this 463 | 464 | add_plus_icon = (node_element) -> 465 | n = d3.select(node_element) 466 | rw = node_element.__data__.rect_width 467 | rh = node_element.__data__.rect_height 468 | 469 | icon = n.insert('use') 470 | .attr('class', 'plus-icon') 471 | .attr('xlink:href', '#plus-icon') 472 | .attr('x', rw/2) 473 | .attr('y', rh - PLUS_ICON_WIDTH/2) 474 | .attr('width', 0) 475 | .attr('height', 0) 476 | icon 477 | .on('mouseover', (d, i) -> icon_ease_in icon, rw) 478 | .on('mouseout', (d, i) -> icon_ease_out icon, rw) 479 | .on('click', (d) -> explore_node d) 480 | 481 | n 482 | .on('mouseover', (d, i) -> icon_ease_in icon, rw) 483 | .on('mouseout', (d, i) -> icon_ease_out icon, rw) 484 | 485 | icon_ease_in = (icon, rw) -> 486 | icon.transition() 487 | .ease('cubic-out') 488 | .duration(200) 489 | .attr('width', PLUS_ICON_WIDTH) 490 | .attr('height', PLUS_ICON_WIDTH) 491 | .attr('x', rw/2 - PLUS_ICON_WIDTH/2) 492 | 493 | icon_ease_out = (icon, rw) -> 494 | icon.transition() 495 | .attr(rw/2 - PLUS_ICON_WIDTH/2) 496 | .ease('cubic-out') 497 | .duration(200) 498 | .attr('width', 0) 499 | .attr('height', 0) 500 | .attr('x', rw/2) 501 | 502 | tip_html = (d) -> 503 | fragment = $(tip_template).clone() 504 | top = fragment.find("#fragment") 505 | title = top.find("p.commit-title") 506 | title.text d.title 507 | 508 | if d.refs 509 | title.append " " 510 | refs = title.children().first() 511 | refs.addClass("commit-describe commit-ref") 512 | .text(d.refs.join(" ")) 513 | 514 | top.find("span.commit-author").text(d.author_name) 515 | date = new Date(d.author_time * 1000) 516 | top.find("time.commit-time") 517 | .attr("datetime", date.toISOString()) 518 | .text(date) 519 | pre = top.find(".commit-body pre").text(d.body) 520 | 521 | if options.debug 522 | # deps = gdd.deps[d.sha1] 523 | # if deps 524 | # sha1s = [gdd.node(sha1).name for name, bool of deps] 525 | # top.append("
Dependencies: " + sha1s.join(", ")); 526 | index = gdd.node_index[d.sha1] 527 | debug = "
node index: " + index 528 | dagre_node = gdl.graph.node(d.sha1) 529 | debug += "
dagre: (#{dagre_node.x}, #{dagre_node.y})" 530 | top.append debug 531 | 532 | # Javascript *sucks*. There's no way to get the outerHTML of a 533 | # document fragment, so you have to wrap the whole thing in a 534 | # single parent and then look that up via children[0]. 535 | return fragment[0].children[0].outerHTML 536 | 537 | translate = (x, y) -> 538 | "translate(#{x},#{y})" 539 | 540 | tick_handler = -> 541 | nodes.each (d) -> 542 | # cola sets the bounds property which is a Rectangle 543 | # representing the space which other nodes should not 544 | # overlap. The innerBounds property seems to tell 545 | # cola the Rectangle which is the visible part of the 546 | # node, minus any blank margin. 547 | d.innerBounds = d.bounds.inflate(-RECT_MARGIN) 548 | 549 | nodes.attr "transform", (d) -> 550 | translate d.innerBounds.x, d.innerBounds.y 551 | 552 | paths.each (d) -> 553 | @parentNode.insertBefore this, this if isIE() 554 | 555 | paths.attr "d", (d) -> 556 | # Undocumented: https://github.com/tgdwyer/WebCola/issues/52 557 | route = cola.makeEdgeBetween \ 558 | d.source.innerBounds, 559 | d.target.innerBounds, 560 | # This value is related to but not equal to the 561 | # distance of arrow tip from object it points at: 562 | 5 563 | 564 | lineData = [ 565 | {x: route.sourceIntersection.x, y: route.sourceIntersection.y}, 566 | {x: route.arrowStart.x, y: route.arrowStart.y} 567 | ] 568 | return lineFunction lineData 569 | 570 | lineFunction = d3.svg.line() 571 | .x((d) -> d.x) 572 | .y((d) -> d.y) 573 | .interpolate("linear") 574 | 575 | routeEdges = -> 576 | d3cola.prepareEdgeRouting EDGE_ROUTING_MARGIN 577 | paths.attr "d", (d) -> 578 | lineFunction d3cola.routeEdge(d) 579 | # show visibility graph 580 | # (g) -> 581 | # if d.source.id == 10 and d.target.id === 11 582 | # g.E.forEach (e) => 583 | # vis.append("line").attr("x1", e.source.p.x).attr("y1", e.source.p.y) 584 | # .attr("x2", e.target.p.x).attr("y2", e.target.p.y) 585 | # .attr("stroke", "green") 586 | 587 | if isIE() 588 | paths.each (d) -> 589 | @parentNode.insertBefore this, this 590 | 591 | isIE = -> 592 | (navigator.appName is "Microsoft Internet Explorer") or 593 | ((navigator.appName is "Netscape") and 594 | ((new RegExp "Trident/.*rv:([0-9]{1,}[.0-9]{0,})") 595 | .exec(navigator.userAgent)?)) 596 | -------------------------------------------------------------------------------- /git_deps/html/js/git-deps-layout.coffee: -------------------------------------------------------------------------------- 1 | DEBUG = false 2 | 3 | MIN_ROW_GAP = 60 4 | MIN_NODE_X_GAP = 100 # presumably includes the node width 5 | MAX_NODE_X_GAP = 300 6 | MAX_NODE_Y_GAP = 80 7 | 8 | dagre = require "dagre" 9 | 10 | gdd = require "./git-deps-data.coffee" 11 | 12 | # The list of constraints to feed into WebCola. 13 | constraints = [] 14 | 15 | # Group nodes by row, as assigned by the y coordinates returned from 16 | # dagre's layout(). This will map a y coordinate onto all nodes 17 | # within that row. 18 | row_groups = {} 19 | 20 | debug = (msg) -> 21 | if exports.debug 22 | console.log msg 23 | 24 | dagre_layout = -> 25 | g = new dagre.graphlib.Graph() 26 | exports.graph = g 27 | 28 | # Set an object for the graph label 29 | g.setGraph {} 30 | 31 | # Default to assigning a new object as a label for each new edge. 32 | g.setDefaultEdgeLabel -> {} 33 | 34 | for node in gdd.nodes 35 | g.setNode node.sha1, 36 | label: node.name 37 | width: node.rect_width or 70 38 | height: node.rect_height or 30 39 | 40 | for parent_sha1, children of gdd.deps 41 | for child_sha1, bool of children 42 | g.setEdge parent_sha1, child_sha1 43 | 44 | dagre.layout g 45 | return g 46 | 47 | dagre_row_groups = -> 48 | g = dagre_layout() 49 | row_groups = {} 50 | exports.row_groups = row_groups 51 | for sha1 in g.nodes() 52 | x = g.node(sha1).x 53 | y = g.node(sha1).y 54 | row_groups[y] = [] unless y of row_groups 55 | row_groups[y].push 56 | sha1: sha1 57 | x: x 58 | 59 | for y, nodes of row_groups 60 | nodes.sort (n) -> -n.x 61 | 62 | return row_groups 63 | 64 | build_constraints = -> 65 | row_groups = dagre_row_groups() 66 | debug "build_constraints" 67 | for y, row_nodes of row_groups 68 | debug y 69 | debug row_nodes 70 | 71 | constraints.length = 0 # FIXME: only rebuild constraints which changed 72 | 73 | # We want alignment constraints between all nodes which dagre 74 | # assigned the same y value. 75 | #row_alignment_constraints(row_groups) 76 | 77 | # We need separation constraints ensuring that the left-to-right 78 | # ordering within each row assigned by dagre is preserved. 79 | for y, row_nodes of row_groups 80 | # No point having an alignment group with only one node in. 81 | continue if row_nodes.length <= 1 82 | 83 | # Multiple constraints per row. 84 | debug "ordering for row y=#{y}" 85 | row_node_ordering_constraints(row_nodes) 86 | debug_constraints() 87 | 88 | # We need separation constraints ensuring that the top-to-bottom 89 | # ordering assigned by dagre is preserved. Since all nodes within 90 | # a single row are already constrained to the same y coordinate 91 | # from above, one would have hoped it would be enough to only have 92 | # separation between a single node in adjacent rows: 93 | # 94 | # row_ordering_constraints(row_groups) 95 | 96 | # However, due to https://github.com/tgdwyer/WebCola/issues/61 97 | # there is more flexibility for y-coordinates within a row than we 98 | # want, so instead we order rows using dependencies. 99 | dependency_ordering_constraints() 100 | 101 | debug_constraints = (cs = constraints) -> 102 | for c in cs 103 | debug c 104 | return 105 | 106 | row_alignment_constraints = (row_groups) -> 107 | row_alignment_constraint(row_nodes) \ 108 | for y, row_nodes of row_groups when row_nodes.length > 1 109 | 110 | row_alignment_constraint = (row_nodes) -> 111 | debug 'row_alignment_constraint' 112 | # A standard alignment constraint (one per row) is too strict 113 | # because it doesn't give cola enough "wiggle room": 114 | # 115 | # constraint = 116 | # axis: "y" 117 | # type: "alignment" 118 | # offsets: [] 119 | # 120 | # for node in row_nodes 121 | # constraint.offsets.push 122 | # node: gdd.node_index[node.sha1], 123 | # offset: 0 124 | # 125 | # constraints.push constraint 126 | # 127 | # So instead we use vertical min/max separation constraints: 128 | i = 0 129 | while i < row_nodes.length - 1 130 | left = row_nodes[i] 131 | right = row_nodes[i+1] 132 | mm = max_unordered_separation_constraints \ 133 | 'y', MAX_NODE_Y_GAP, 134 | gdd.node_index[left.sha1], 135 | gdd.node_index[right.sha1] 136 | exports.constraints = constraints = constraints.concat mm 137 | i++ 138 | debug_constraints() 139 | return 140 | 141 | row_node_ordering_constraints = (row_nodes) -> 142 | debug 'row_node_ordering_constraints' 143 | i = 0 144 | while i < row_nodes.length - 1 145 | left = row_nodes[i] 146 | right = row_nodes[i+1] 147 | left_i = gdd.node_index[left.sha1] 148 | right_i = gdd.node_index[right.sha1] 149 | debug " #{left_i} < #{right_i} (#{left.x} < #{right.x})" 150 | # mm = min_max_ordered_separation_constraints \ 151 | # 'x', MIN_NODE_X_GAP, MAX_NODE_X_GAP, left_i, right_i 152 | min = min_separation_constraint \ 153 | 'x', MIN_NODE_X_GAP, left_i, right_i 154 | exports.constraints = constraints = constraints.concat min 155 | i++ 156 | return 157 | 158 | row_ordering_constraints = (row_groups) -> 159 | debug 'row_ordering_constraints' 160 | row_y_coords = Object.keys(row_groups).sort() 161 | 162 | i = 0 163 | while i < row_y_coords.length - 1 164 | upper_y = row_y_coords[i] 165 | lower_y = row_y_coords[i + 1] 166 | upper_node = row_groups[upper_y][0] 167 | lower_node = row_groups[lower_y][0] 168 | constraints.push \ 169 | min_separation_constraint \ 170 | 'y', MIN_ROW_GAP, 171 | gdd.node_index[upper_node.sha1], 172 | gdd.node_index[lower_node.sha1] 173 | 174 | i++ 175 | debug_constraints() 176 | return 177 | 178 | dependency_ordering_constraints = () -> 179 | debug 'dependency_ordering_constraints' 180 | 181 | for parent_sha1, children of gdd.deps 182 | child_sha1s = Object.keys(children).sort (sha1) -> node(sha1).x 183 | dependency_ordering_constraint(parent_sha1, child_sha1s[0]) 184 | len = child_sha1s.length 185 | if len > 1 186 | dependency_ordering_constraint(parent_sha1, child_sha1s[len-1]) 187 | if len > 2 188 | middle = Math.floor(len / 2) 189 | dependency_ordering_constraint(parent_sha1, child_sha1s[middle]) 190 | 191 | debug_constraints() 192 | return 193 | 194 | dependency_ordering_constraint = (parent_sha1, child_sha1) -> 195 | constraints.push \ 196 | min_separation_constraint \ 197 | 'y', MIN_ROW_GAP, 198 | gdd.node_index[parent_sha1], 199 | gdd.node_index[child_sha1] 200 | 201 | ################################################################## 202 | # helpers 203 | 204 | # Uses approach explained here: 205 | # https://github.com/tgdwyer/WebCola/issues/62#issuecomment-69571870 206 | min_max_ordered_separation_constraints = (axis, min, max, left, right) -> 207 | return [ 208 | min_separation_constraint(axis, min, left, right), 209 | max_separation_constraint(axis, max, left, right) 210 | ] 211 | 212 | # https://github.com/tgdwyer/WebCola/issues/66 213 | max_unordered_separation_constraints = (axis, max, left, right) -> 214 | return [ 215 | max_separation_constraint(axis, max, left, right), 216 | max_separation_constraint(axis, max, right, left) 217 | ] 218 | 219 | min_separation_constraint = (axis, gap, left, right) -> 220 | {} = 221 | axis: axis 222 | gap: gap 223 | left: left 224 | right: right 225 | 226 | # We use a negative gap and reverse the inequality, in order to 227 | # achieve a maximum rather than minimum separation gap. However this 228 | # does not prevent the nodes from overlapping or even swapping order. 229 | # For that you also need a min_separation_constraint, but it's more 230 | # convenient to use min_max_ordered_separation_constraints. See 231 | # https://github.com/tgdwyer/WebCola/issues/62#issuecomment-69571870 232 | # for more details. 233 | max_separation_constraint = (axis, gap, left, right) -> 234 | {} = 235 | axis: axis 236 | gap: -gap 237 | left: right 238 | right: left 239 | 240 | node = (sha1) -> 241 | exports.graph.node sha1 242 | 243 | module.exports = exports = 244 | # Variables have to be exported every time they're assigned, 245 | # since assignment creates a new object and associated reference 246 | 247 | # Functions 248 | build_constraints: build_constraints 249 | debug_constraints: debug_constraints 250 | node: node 251 | 252 | # Variables 253 | debug: DEBUG 254 | -------------------------------------------------------------------------------- /git_deps/html/js/git-deps-noty.coffee: -------------------------------------------------------------------------------- 1 | noty = require "noty" 2 | 3 | # Different noty types: 4 | # alert, success, error, warning, information, confirmation 5 | noty_error = (text) -> notyfication "error", text 6 | noty_warn = (text) -> notyfication "warning", text 7 | noty_success = (text) -> notyfication "success", text 8 | noty_info = (text) -> notyfication "information", text 9 | noty_debug = (text) -> notyfication "information", text 10 | 11 | # "notyfication" - haha, did you see what I did there? 12 | notyfication = (type, text) -> 13 | noty( 14 | text: text 15 | type: type 16 | layout: "topRight" 17 | theme: "relax" 18 | maxVisible: 15 19 | timeout: 30000 # ms 20 | animation: 21 | open: "animated bounceInUp" # Animate.css class names 22 | close: "animated bounceOutUp" # Animate.css class names 23 | easing: "swing" # unavailable - no need 24 | speed: 500 # unavailable - no need 25 | ) 26 | 27 | module.exports = 28 | error: noty_error 29 | warn: noty_warn 30 | success: noty_success 31 | info: noty_info 32 | debug: noty_debug 33 | -------------------------------------------------------------------------------- /git_deps/html/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "git-deps", 3 | "version": "0.1.0", 4 | "authors": [ 5 | "Adam Spiers" 6 | ], 7 | "description": "tool for performing automatic analysis of dependencies between git commits", 8 | "main": "git-deps", 9 | "keywords": [ 10 | "git", 11 | "dependency", 12 | "analysis", 13 | "scm", 14 | "graphing", 15 | "visualization" 16 | ], 17 | "license": "GPL-2.0", 18 | "homepage": "https://github.com/aspiers/git-deps", 19 | "private": true, 20 | "ignore": [ 21 | "**/.*", 22 | "node_modules", 23 | "bower_components", 24 | "test", 25 | "tests" 26 | ], 27 | "dependencies": { 28 | "browserify": "*", 29 | "coffeeify": "~1.0.0", 30 | "d3": "~3.5.3", 31 | "d3-tip": "~0.6.6", 32 | "dagre": "^0.8.2", 33 | "jquery": "~3.5.0", 34 | "noty": "~v2.4.1", 35 | "webcola": "aspiers/WebCola#git-deps-master" 36 | }, 37 | "devDependencies": { 38 | "watchify": "*" 39 | }, 40 | "directories": { 41 | "test": "test" 42 | }, 43 | "scripts": { 44 | "test": "echo \"Error: no test specified\" && exit 1" 45 | }, 46 | "repository": { 47 | "type": "git", 48 | "url": "https://github.com/aspiers/git-deps" 49 | }, 50 | "author": "Adam Spiers", 51 | "bugs": { 52 | "url": "https://github.com/aspiers/git-deps/issues" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /git_deps/html/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "commits": [ 3 | { 4 | "author_mail": "git@adamspiers.org", 5 | "author_name": "Adam Spiers", 6 | "author_offset": 0, 7 | "author_time": 1420486941, 8 | "body": "This creates the JSON which will eventually be consumed by\nthe Javascript visualizer.\n", 9 | "committer_mail": "git@adamspiers.org", 10 | "committer_name": "Adam Spiers", 11 | "committer_offset": 0, 12 | "committer_time": 1420487137, 13 | "describe": "tags/test-0", 14 | "name": "2b6d591", 15 | "separator": "\n", 16 | "sha": "2b6d5915f6433b9eb1685751b82cfbebcbb37981", 17 | "title": "add JSON listener" 18 | }, 19 | { 20 | "author_mail": "git@adamspiers.org", 21 | "author_name": "Adam Spiers", 22 | "author_offset": -300, 23 | "author_time": 1384447153, 24 | "body": "Automatic git commit dependency inference tool.\n\nOriginally committed to:\n\n https://github.com/aspiers/git-config/blob/master/bin/git-deps\n\nand then split off into this repository via git filter-branch\nand other hackery, preserving history.\n", 25 | "committer_mail": "git@adamspiers.org", 26 | "committer_name": "Adam Spiers", 27 | "committer_offset": 0, 28 | "committer_time": 1420476874, 29 | "describe": "", 30 | "name": "b196757", 31 | "separator": "\n", 32 | "sha": "b1967573e81a8100a4cc778936de0ba0a8a8f5cb", 33 | "title": "first prototype of git-deps" 34 | }, 35 | { 36 | "author_mail": "git@adamspiers.org", 37 | "author_name": "Adam Spiers", 38 | "author_offset": -300, 39 | "author_time": 1384471712, 40 | "body": "", 41 | "committer_mail": "git@adamspiers.org", 42 | "committer_name": "Adam Spiers", 43 | "committer_offset": 0, 44 | "committer_time": 1420477027, 45 | "describe": "", 46 | "name": "3a1dd42", 47 | "separator": "\n", 48 | "sha": "3a1dd42fd6114a634ba7cf037ce61e2aee76db73", 49 | "title": "add logging and recursion" 50 | }, 51 | { 52 | "author_mail": "git@adamspiers.org", 53 | "author_name": "Adam Spiers", 54 | "author_offset": -300, 55 | "author_time": 1384563102, 56 | "body": "", 57 | "committer_mail": "git@adamspiers.org", 58 | "committer_name": "Adam Spiers", 59 | "committer_offset": 0, 60 | "committer_time": 1420477027, 61 | "describe": "", 62 | "name": "3374b84", 63 | "separator": "\n", 64 | "sha": "3374b8419a45d91d3c0631be11c8cf893b272217", 65 | "title": "add listener classes" 66 | }, 67 | { 68 | "author_mail": "git@adamspiers.org", 69 | "author_name": "Adam Spiers", 70 | "author_offset": 0, 71 | "author_time": 1420483579, 72 | "body": "", 73 | "committer_mail": "git@adamspiers.org", 74 | "committer_name": "Adam Spiers", 75 | "committer_offset": 0, 76 | "committer_time": 1420483579, 77 | "describe": "", 78 | "name": "ff82dda", 79 | "separator": "\n", 80 | "sha": "ff82dda196947650bd497301e61b282753193564", 81 | "title": "fix a bunch of PEP8 issues" 82 | }, 83 | { 84 | "author_mail": "git@adamspiers.org", 85 | "author_name": "Adam Spiers", 86 | "author_offset": -300, 87 | "author_time": 1384579026, 88 | "body": "", 89 | "committer_mail": "git@adamspiers.org", 90 | "committer_name": "Adam Spiers", 91 | "committer_offset": 0, 92 | "committer_time": 1420477028, 93 | "describe": "", 94 | "name": "8d44254", 95 | "separator": "\n", 96 | "sha": "8d442544a20b706b996d66ab390a16fd97b48d6d", 97 | "title": "use new-style classes" 98 | }, 99 | { 100 | "author_mail": "git@adamspiers.org", 101 | "author_name": "Adam Spiers", 102 | "author_offset": -300, 103 | "author_time": 1384452615, 104 | "body": "", 105 | "committer_mail": "git@adamspiers.org", 106 | "committer_name": "Adam Spiers", 107 | "committer_offset": 0, 108 | "committer_time": 1420477027, 109 | "describe": "", 110 | "name": "b144bfd", 111 | "separator": "\n", 112 | "sha": "b144bfd5feb327ef7ce0c26bbfb6f4da573abfe5", 113 | "title": "refactor into new DependencyDetector class" 114 | }, 115 | { 116 | "author_mail": "git@adamspiers.org", 117 | "author_name": "Adam Spiers", 118 | "author_offset": 0, 119 | "author_time": 1420482533, 120 | "body": "", 121 | "committer_mail": "git@adamspiers.org", 122 | "committer_name": "Adam Spiers", 123 | "committer_offset": 0, 124 | "committer_time": 1420482533, 125 | "describe": "", 126 | "name": "e406002", 127 | "separator": "\n", 128 | "sha": "e40600230d1c3059485437bd4d5690d61c9edb2f", 129 | "title": "don't show \"[False]\" default for boolean options" 130 | }, 131 | { 132 | "author_mail": "git@adamspiers.org", 133 | "author_name": "Adam Spiers", 134 | "author_offset": -300, 135 | "author_time": 1384650040, 136 | "body": "", 137 | "committer_mail": "git@adamspiers.org", 138 | "committer_name": "Adam Spiers", 139 | "committer_offset": 0, 140 | "committer_time": 1420477028, 141 | "describe": "", 142 | "name": "4f27a1e", 143 | "separator": "\n", 144 | "sha": "4f27a1ee2b5fd63a58311a20e2aed0a24eda8da2", 145 | "title": "add --exclude-commits" 146 | }, 147 | { 148 | "author_mail": "git@adamspiers.org", 149 | "author_name": "Adam Spiers", 150 | "author_offset": -300, 151 | "author_time": 1384656388, 152 | "body": "", 153 | "committer_mail": "git@adamspiers.org", 154 | "committer_name": "Adam Spiers", 155 | "committer_offset": 0, 156 | "committer_time": 1420477028, 157 | "describe": "", 158 | "name": "824f84c", 159 | "separator": "\n", 160 | "sha": "824f84cd594254d0c87f330b855153fc5ffe5ad3", 161 | "title": "add installation instructions" 162 | }, 163 | { 164 | "author_mail": "git@adamspiers.org", 165 | "author_name": "Adam Spiers", 166 | "author_offset": -300, 167 | "author_time": 1384579202, 168 | "body": "", 169 | "committer_mail": "git@adamspiers.org", 170 | "committer_name": "Adam Spiers", 171 | "committer_offset": 0, 172 | "committer_time": 1420477028, 173 | "describe": "", 174 | "name": "6240939", 175 | "separator": "\n", 176 | "sha": "62409395e260ad01f9ae7b84869f5516ef80c7aa", 177 | "title": "output dependencies as soon as they're found" 178 | }, 179 | { 180 | "author_mail": "git@adamspiers.org", 181 | "author_name": "Adam Spiers", 182 | "author_offset": -300, 183 | "author_time": 1384654780, 184 | "body": "", 185 | "committer_mail": "git@adamspiers.org", 186 | "committer_name": "Adam Spiers", 187 | "committer_offset": 0, 188 | "committer_time": 1420477028, 189 | "describe": "", 190 | "name": "5071249", 191 | "separator": "\n", 192 | "sha": "5071249715e82dcf3c1db12eec28c1232aba2142", 193 | "title": "avoid adding entries to TODO queue multiple times" 194 | }, 195 | { 196 | "author_mail": "git@adamspiers.org", 197 | "author_name": "Adam Spiers", 198 | "author_offset": -300, 199 | "author_time": 1384566591, 200 | "body": "", 201 | "committer_mail": "git@adamspiers.org", 202 | "committer_name": "Adam Spiers", 203 | "committer_offset": 0, 204 | "committer_time": 1420477027, 205 | "describe": "", 206 | "name": "6e86e8b", 207 | "separator": "\n", 208 | "sha": "6e86e8b7f648bd6a3a6d3216aa5899414b65cbed", 209 | "title": "don't crash on commits which only add files" 210 | }, 211 | { 212 | "author_mail": "git@adamspiers.org", 213 | "author_name": "Adam Spiers", 214 | "author_offset": -300, 215 | "author_time": 1384563081, 216 | "body": "", 217 | "committer_mail": "git@adamspiers.org", 218 | "committer_name": "Adam Spiers", 219 | "committer_offset": 0, 220 | "committer_time": 1420477027, 221 | "describe": "", 222 | "name": "acc24a4", 223 | "separator": "\n", 224 | "sha": "acc24a404d82061bbc6db5afb146d83bf131830b", 225 | "title": "add --context-lines" 226 | }, 227 | { 228 | "author_mail": "git@adamspiers.org", 229 | "author_name": "Adam Spiers", 230 | "author_offset": -300, 231 | "author_time": 1384567428, 232 | "body": "", 233 | "committer_mail": "git@adamspiers.org", 234 | "committer_name": "Adam Spiers", 235 | "committer_offset": 0, 236 | "committer_time": 1420477028, 237 | "describe": "", 238 | "name": "5ec5ccb", 239 | "separator": "\n", 240 | "sha": "5ec5ccbdff508014c61ae9d18f3366a15c0f2689", 241 | "title": "add first line of commits to debug" 242 | }, 243 | { 244 | "author_mail": "git@adamspiers.org", 245 | "author_name": "Adam Spiers", 246 | "author_offset": -300, 247 | "author_time": 1384563342, 248 | "body": "", 249 | "committer_mail": "git@adamspiers.org", 250 | "committer_name": "Adam Spiers", 251 | "committer_offset": 0, 252 | "committer_time": 1420477027, 253 | "describe": "", 254 | "name": "f2cddb4", 255 | "separator": "\n", 256 | "sha": "f2cddb4aa00de4ddff2cdca251758e25e95e04ad", 257 | "title": "tweaks to improve debugging" 258 | }, 259 | { 260 | "author_mail": "git@adamspiers.org", 261 | "author_name": "Adam Spiers", 262 | "author_offset": -300, 263 | "author_time": 1384651799, 264 | "body": "", 265 | "committer_mail": "git@adamspiers.org", 266 | "committer_name": "Adam Spiers", 267 | "committer_offset": 0, 268 | "committer_time": 1420477028, 269 | "describe": "", 270 | "name": "1b66efa", 271 | "separator": "\n", 272 | "sha": "1b66efa173a19a8b4c0c47274a1b9cdd8b9912af", 273 | "title": "improve help text" 274 | }, 275 | { 276 | "author_mail": "git@adamspiers.org", 277 | "author_name": "Adam Spiers", 278 | "author_offset": -300, 279 | "author_time": 1384567528, 280 | "body": "", 281 | "committer_mail": "git@adamspiers.org", 282 | "committer_name": "Adam Spiers", 283 | "committer_offset": 0, 284 | "committer_time": 1420477028, 285 | "describe": "", 286 | "name": "80c247f", 287 | "separator": "\n", 288 | "sha": "80c247fd21a1e7f476d1c8ba289498e216eff3dc", 289 | "title": "--help: put short options first" 290 | }, 291 | { 292 | "author_mail": "git@adamspiers.org", 293 | "author_name": "Adam Spiers", 294 | "author_offset": -300, 295 | "author_time": 1384567489, 296 | "body": "", 297 | "committer_mail": "git@adamspiers.org", 298 | "committer_name": "Adam Spiers", 299 | "committer_offset": 0, 300 | "committer_time": 1420477028, 301 | "describe": "", 302 | "name": "2ebcb2b", 303 | "separator": "\n", 304 | "sha": "2ebcb2b6081e32e9a463519525bd432287b24520", 305 | "title": "improve --help for --context-lines" 306 | }, 307 | { 308 | "author_mail": "git@adamspiers.org", 309 | "author_name": "Adam Spiers", 310 | "author_offset": -300, 311 | "author_time": 1384636660, 312 | "body": "", 313 | "committer_mail": "git@adamspiers.org", 314 | "committer_name": "Adam Spiers", 315 | "committer_offset": 0, 316 | "committer_time": 1420477028, 317 | "describe": "", 318 | "name": "f7bf058", 319 | "separator": "\n", 320 | "sha": "f7bf058439fd7499aad7a10418a9f516e6949fbc", 321 | "title": "allow multiple dependents on ARGV, and fix usage string" 322 | }, 323 | { 324 | "author_mail": "git@adamspiers.org", 325 | "author_name": "Adam Spiers", 326 | "author_offset": -300, 327 | "author_time": 1384612401, 328 | "body": "", 329 | "committer_mail": "git@adamspiers.org", 330 | "committer_name": "Adam Spiers", 331 | "committer_offset": 0, 332 | "committer_time": 1420477028, 333 | "describe": "", 334 | "name": "2a05400", 335 | "separator": "\n", 336 | "sha": "2a05400e232e14f0d4c1cbfb548a0871ea57bd44", 337 | "title": "ignore KeyboardInterrupt" 338 | }, 339 | { 340 | "author_mail": "git@adamspiers.org", 341 | "author_name": "Adam Spiers", 342 | "author_offset": -300, 343 | "author_time": 1384652004, 344 | "body": "", 345 | "committer_mail": "git@adamspiers.org", 346 | "committer_name": "Adam Spiers", 347 | "committer_offset": 0, 348 | "committer_time": 1420477028, 349 | "describe": "", 350 | "name": "4364944", 351 | "separator": "\n", 352 | "sha": "43649442f49876ad22051b085a9258f39bbcd5c6", 353 | "title": "fix error message" 354 | } 355 | ], 356 | "dependencies": [ 357 | { 358 | "child": "b1967573e81a8100a4cc778936de0ba0a8a8f5cb", 359 | "parent": "2b6d5915f6433b9eb1685751b82cfbebcbb37981" 360 | }, 361 | { 362 | "child": "3a1dd42fd6114a634ba7cf037ce61e2aee76db73", 363 | "parent": "2b6d5915f6433b9eb1685751b82cfbebcbb37981" 364 | }, 365 | { 366 | "child": "3374b8419a45d91d3c0631be11c8cf893b272217", 367 | "parent": "2b6d5915f6433b9eb1685751b82cfbebcbb37981" 368 | }, 369 | { 370 | "child": "ff82dda196947650bd497301e61b282753193564", 371 | "parent": "2b6d5915f6433b9eb1685751b82cfbebcbb37981" 372 | }, 373 | { 374 | "child": "8d442544a20b706b996d66ab390a16fd97b48d6d", 375 | "parent": "2b6d5915f6433b9eb1685751b82cfbebcbb37981" 376 | }, 377 | { 378 | "child": "b144bfd5feb327ef7ce0c26bbfb6f4da573abfe5", 379 | "parent": "2b6d5915f6433b9eb1685751b82cfbebcbb37981" 380 | }, 381 | { 382 | "child": "e40600230d1c3059485437bd4d5690d61c9edb2f", 383 | "parent": "2b6d5915f6433b9eb1685751b82cfbebcbb37981" 384 | }, 385 | { 386 | "child": "4f27a1ee2b5fd63a58311a20e2aed0a24eda8da2", 387 | "parent": "2b6d5915f6433b9eb1685751b82cfbebcbb37981" 388 | }, 389 | { 390 | "child": "824f84cd594254d0c87f330b855153fc5ffe5ad3", 391 | "parent": "ff82dda196947650bd497301e61b282753193564" 392 | }, 393 | { 394 | "child": "62409395e260ad01f9ae7b84869f5516ef80c7aa", 395 | "parent": "ff82dda196947650bd497301e61b282753193564" 396 | }, 397 | { 398 | "child": "5071249715e82dcf3c1db12eec28c1232aba2142", 399 | "parent": "ff82dda196947650bd497301e61b282753193564" 400 | }, 401 | { 402 | "child": "6e86e8b7f648bd6a3a6d3216aa5899414b65cbed", 403 | "parent": "ff82dda196947650bd497301e61b282753193564" 404 | }, 405 | { 406 | "child": "acc24a404d82061bbc6db5afb146d83bf131830b", 407 | "parent": "ff82dda196947650bd497301e61b282753193564" 408 | }, 409 | { 410 | "child": "5ec5ccbdff508014c61ae9d18f3366a15c0f2689", 411 | "parent": "ff82dda196947650bd497301e61b282753193564" 412 | }, 413 | { 414 | "child": "f2cddb4aa00de4ddff2cdca251758e25e95e04ad", 415 | "parent": "ff82dda196947650bd497301e61b282753193564" 416 | }, 417 | { 418 | "child": "1b66efa173a19a8b4c0c47274a1b9cdd8b9912af", 419 | "parent": "ff82dda196947650bd497301e61b282753193564" 420 | }, 421 | { 422 | "child": "80c247fd21a1e7f476d1c8ba289498e216eff3dc", 423 | "parent": "ff82dda196947650bd497301e61b282753193564" 424 | }, 425 | { 426 | "child": "2ebcb2b6081e32e9a463519525bd432287b24520", 427 | "parent": "ff82dda196947650bd497301e61b282753193564" 428 | }, 429 | { 430 | "child": "f7bf058439fd7499aad7a10418a9f516e6949fbc", 431 | "parent": "ff82dda196947650bd497301e61b282753193564" 432 | }, 433 | { 434 | "child": "2a05400e232e14f0d4c1cbfb548a0871ea57bd44", 435 | "parent": "ff82dda196947650bd497301e61b282753193564" 436 | }, 437 | { 438 | "child": "43649442f49876ad22051b085a9258f39bbcd5c6", 439 | "parent": "5071249715e82dcf3c1db12eec28c1232aba2142" 440 | } 441 | ] 442 | } 443 | -------------------------------------------------------------------------------- /git_deps/html/tip-template.html: -------------------------------------------------------------------------------- 1 |
2 |

3 |

4 | 5 | authored on 6 |
8 |
9 |
10 |   
11 |
12 | -------------------------------------------------------------------------------- /git_deps/listener/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /git_deps/listener/base.py: -------------------------------------------------------------------------------- 1 | class DependencyListener(object): 2 | """Class for listening to result events generated by 3 | DependencyDetector. Add an instance of this class to a 4 | DependencyDetector instance via DependencyDetector.add_listener(). 5 | """ 6 | 7 | def __init__(self, options): 8 | self.options = options 9 | 10 | def set_detector(self, detector): 11 | self.detector = detector 12 | 13 | def repo(self): 14 | return self.detector.repo 15 | 16 | def new_commit(self, commit): 17 | pass 18 | 19 | def new_dependent(self, dependent): 20 | pass 21 | 22 | def new_dependency(self, dependent, dependency, path, line_num): 23 | pass 24 | 25 | def new_path(self, dependent, dependency, path, line_num): 26 | pass 27 | 28 | def new_line(self, dependent, dependency, path, line_num): 29 | pass 30 | 31 | def dependent_done(self, dependent, dependencies): 32 | pass 33 | 34 | def all_done(self): 35 | pass 36 | -------------------------------------------------------------------------------- /git_deps/listener/cli.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | from git_deps.listener.base import DependencyListener 4 | 5 | 6 | class CLIDependencyListener(DependencyListener): 7 | """Dependency listener for use when running in CLI mode. 8 | 9 | This allows us to output dependencies as they are discovered, 10 | rather than waiting for all dependencies to be discovered before 11 | outputting anything; the latter approach can make the user wait 12 | too long for useful output if recursion is enabled. 13 | """ 14 | 15 | def __init__(self, options): 16 | super(CLIDependencyListener, self).__init__(options) 17 | 18 | # Count each mention of each revision, so we can avoid duplicating 19 | # commits in the output. 20 | self._revs = {} 21 | 22 | def new_commit(self, commit): 23 | rev = commit.hex 24 | if rev not in self._revs: 25 | self._revs[rev] = 0 26 | self._revs[rev] += 1 27 | 28 | def new_dependency(self, dependent, dependency, path, line_num): 29 | dependent_sha1 = dependent.hex 30 | dependency_sha1 = dependency.hex 31 | 32 | if self.options.multi: 33 | if self.options.log: 34 | print("%s depends on:" % dependent_sha1) 35 | if self._revs[dependency_sha1] > 1: 36 | print("commit %s (already shown above)\n" 37 | % dependency_sha1) 38 | else: 39 | print("%s %s" % (dependent_sha1, dependency_sha1)) 40 | else: 41 | if not self.options.log and self._revs[dependency_sha1] <= 1: 42 | print(dependency_sha1) 43 | 44 | if self.options.log and self._revs[dependency_sha1] <= 1: 45 | cmd = [ 46 | 'git', 47 | '--no-pager', 48 | '-c', 'color.ui=always', 49 | 'log', '-n1', 50 | dependency_sha1 51 | ] 52 | print(subprocess.check_output(cmd, universal_newlines=True)) 53 | # dependency = detector.get_commit(dependency_sha1) 54 | # print(dependency.message + "\n") 55 | 56 | # for path in self.dependencies[dependency]: 57 | # print(" %s" % path) 58 | # keys = sorted(self.dependencies[dependency][path].keys() 59 | # print(" %s" % ", ".join(keys))) 60 | -------------------------------------------------------------------------------- /git_deps/listener/json.py: -------------------------------------------------------------------------------- 1 | from git_deps.listener.base import DependencyListener 2 | 3 | from git_deps.gitutils import GitUtils 4 | 5 | 6 | class JSONDependencyListener(DependencyListener): 7 | """Dependency listener for use when compiling graph data in a JSON 8 | format which can be consumed by WebCola / d3. Each new commit has 9 | to be added to a 'commits' array. 10 | """ 11 | 12 | def __init__(self, options): 13 | super(JSONDependencyListener, self).__init__(options) 14 | 15 | # Map commit names to indices in the commits array. This is used 16 | # to avoid the risk of duplicates in the commits array, which 17 | # could happen when recursing, since multiple commits could 18 | # potentially depend on the same commit. 19 | self._commits = {} 20 | 21 | self._json = { 22 | 'commits': [], 23 | 'dependencies': [], 24 | } 25 | 26 | def get_commit(self, sha1): 27 | i = self._commits[sha1] 28 | return self._json['commits'][i] 29 | 30 | def add_commit(self, commit): 31 | """Adds the commit to the commits array if it doesn't already exist, 32 | and returns the commit's index in the array. 33 | """ 34 | sha1 = commit.hex 35 | if sha1 in self._commits: 36 | return self._commits[sha1] 37 | title, separator, body = commit.message.partition("\n") 38 | commit = { 39 | 'explored': False, 40 | 'sha1': sha1, 41 | 'name': GitUtils.abbreviate_sha1(sha1), 42 | 'describe': GitUtils.describe(sha1), 43 | 'refs': GitUtils.refs_to(sha1, self.repo()), 44 | 'author_name': commit.author.name, 45 | 'author_mail': commit.author.email, 46 | 'author_time': commit.author.time, 47 | 'author_offset': commit.author.offset, 48 | 'committer_name': commit.committer.name, 49 | 'committer_mail': commit.committer.email, 50 | 'committer_time': commit.committer.time, 51 | 'committer_offset': commit.committer.offset, 52 | # 'message': commit.message, 53 | 'title': title, 54 | 'separator': separator, 55 | 'body': body.lstrip("\n"), 56 | } 57 | self._json['commits'].append(commit) 58 | self._commits[sha1] = len(self._json['commits']) - 1 59 | return self._commits[sha1] 60 | 61 | def new_commit(self, commit): 62 | self.add_commit(commit) 63 | 64 | def new_dependency(self, parent, child, path, line_num): 65 | ph = parent.hex 66 | ch = child.hex 67 | 68 | new_dep = { 69 | 'parent': ph, 70 | 'child': ch, 71 | } 72 | 73 | if self.options.log: 74 | pass # FIXME 75 | 76 | self._json['dependencies'].append(new_dep) 77 | 78 | def dependent_done(self, dependent, dependencies): 79 | commit = self.get_commit(dependent.hex) 80 | commit['explored'] = True 81 | 82 | def json(self): 83 | return self._json 84 | -------------------------------------------------------------------------------- /git_deps/server.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | 5 | from git_deps.gitutils import GitUtils 6 | from git_deps.detector import DependencyDetector 7 | from git_deps.errors import InvalidCommitish 8 | from git_deps.listener.json import JSONDependencyListener 9 | from git_deps.utils import abort, standard_logger 10 | 11 | 12 | def serve(options): 13 | try: 14 | import flask 15 | from flask import Flask, send_file 16 | from flask.json import jsonify 17 | from werkzeug.security import safe_join 18 | except ImportError: 19 | abort("Cannot find flask module which is required for webserver mode.") 20 | 21 | logger = standard_logger(__name__, options.debug) 22 | 23 | webserver = Flask('git-deps') 24 | here = os.path.dirname(os.path.realpath(__file__)) 25 | root = os.path.join(here, 'html') 26 | webserver.root_path = root 27 | logger.debug("Webserver root is %s" % root) 28 | 29 | ########################################################## 30 | # Static content 31 | 32 | @webserver.route('/') 33 | def main_page(): 34 | return send_file('git-deps.html') 35 | 36 | @webserver.route('/tip-template.html') 37 | def tip_template(): 38 | return send_file('tip-template.html') 39 | 40 | @webserver.route('/test.json') 41 | def data(): 42 | return send_file('test.json') 43 | 44 | def make_subdir_handler(subdir): 45 | def subdir_handler(filename): 46 | path = safe_join(root, subdir) 47 | path = safe_join(path, filename) 48 | if os.path.exists(path): 49 | return send_file(path) 50 | else: 51 | flask.abort(404) 52 | return subdir_handler 53 | 54 | for subdir in ('node_modules', 'css', 'js'): 55 | fn = make_subdir_handler(subdir) 56 | route = '/%s/' % subdir 57 | webserver.add_url_rule(route, subdir + '_handler', fn) 58 | 59 | ########################################################## 60 | # Dynamic content 61 | 62 | def json_error(status_code, error_class, message, **extra): 63 | json = { 64 | 'status': status_code, 65 | 'error_class': error_class, 66 | 'message': message, 67 | } 68 | json.update(extra) 69 | response = jsonify(json) 70 | response.status_code = status_code 71 | return response 72 | 73 | @webserver.route('/options') 74 | def send_options(): 75 | client_options = options.__dict__ 76 | client_options['repo_path'] = os.getcwd() 77 | return jsonify(client_options) 78 | 79 | @webserver.route('/deps.json/') 80 | def deps(revspec): 81 | detector = DependencyDetector(options) 82 | listener = JSONDependencyListener(options) 83 | detector.add_listener(listener) 84 | 85 | if '..' in revspec: 86 | try: 87 | revisions = GitUtils.rev_list(revspec) 88 | except subprocess.CalledProcessError: 89 | return json_error( 90 | 422, 'Invalid revision range', 91 | "Could not resolve revision range '%s'" % revspec, 92 | revspec=revspec) 93 | else: 94 | revisions = [revspec] 95 | 96 | for rev in revisions: 97 | try: 98 | detector.get_commit(rev) 99 | except InvalidCommitish: 100 | return json_error( 101 | 422, 'Invalid revision', 102 | "Could not resolve revision '%s'" % rev, 103 | rev=rev) 104 | 105 | detector.find_dependencies(rev) 106 | 107 | tip_commit = detector.get_commit(revisions[0]) 108 | tip_sha1 = tip_commit.hex 109 | 110 | json = listener.json() 111 | json['query'] = { 112 | 'revspec': revspec, 113 | 'revisions': revisions, 114 | 'tip_sha1': tip_sha1, 115 | 'tip_abbrev': GitUtils.abbreviate_sha1(tip_sha1), 116 | } 117 | return jsonify(json) 118 | 119 | # We don't want to see double-decker warnings, so check 120 | # WERKZEUG_RUN_MAIN which is only set for the first startup, not 121 | # on app reloads. 122 | if options.debug and not os.getenv('WERKZEUG_RUN_MAIN'): 123 | print("!! WARNING! Debug mode enabled, so webserver is completely " 124 | "insecure!") 125 | print("!! Arbitrary code can be executed from browser!") 126 | print() 127 | try: 128 | webserver.run(port=options.port, debug=options.debug, 129 | host=options.bindaddr) 130 | except OSError as e: 131 | print("\n!!! ERROR: Could not start server:") 132 | print("!!!") 133 | print("!!! " + str(e)) 134 | print("!!!") 135 | if e.strerror == "Address already in use": 136 | print("!!! Do you already have a git deps server running?") 137 | print("!!! If so, stop it first and try again.") 138 | print("!!!") 139 | print("!!! Aborting.") 140 | sys.exit(1) 141 | -------------------------------------------------------------------------------- /git_deps/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import logging 4 | import sys 5 | 6 | 7 | def abort(msg, exitcode=1): 8 | print(msg, file=sys.stderr) 9 | sys.exit(exitcode) 10 | 11 | 12 | def standard_logger(name, debug): 13 | if not debug: 14 | return logging.getLogger(name) 15 | 16 | return debug_logger(name) 17 | 18 | 19 | def debug_logger(name): 20 | log_format = '%(asctime)-15s %(levelname)-6s %(message)s' 21 | date_format = '%b %d %H:%M:%S' 22 | formatter = logging.Formatter(fmt=log_format, datefmt=date_format) 23 | handler = logging.StreamHandler(stream=sys.stdout) 24 | handler.setFormatter(formatter) 25 | logger = logging.getLogger(name) 26 | logger.setLevel(logging.DEBUG) 27 | logger.addHandler(handler) 28 | return logger 29 | -------------------------------------------------------------------------------- /images/youtube-porting-thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aspiers/git-deps/HEAD/images/youtube-porting-thumbnail.png -------------------------------------------------------------------------------- /images/youtube-thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aspiers/git-deps/HEAD/images/youtube-thumbnail.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pip >= 10.0.1 2 | pygit2 >= 0.24.0 3 | flask 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = git-deps 3 | summary = automatically detect dependencies between git commits 4 | author = Adam Spiers 5 | author_email = git@adamspiers.org 6 | license = GPL-2+ 7 | home_page = https://github.com/aspiers/git-deps 8 | description_file = README.md 9 | classifier = 10 | Development Status :: 4 - Beta 11 | Environment :: Console 12 | Environment :: Web Environment 13 | Framework :: Flask 14 | Intended Audience :: Developers 15 | License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+) 16 | Natural Language :: English 17 | Operating System :: OS Independent 18 | Programming Language :: Python 19 | Topic :: Software Development :: Version Control 20 | Topic :: Utilities 21 | 22 | [entry_points] 23 | console_scripts = 24 | git-deps = git_deps.cli:run 25 | gitfile-handler = git_deps.handler:run 26 | 27 | [files] 28 | scripts = 29 | bin/git-fixup 30 | data_files = 31 | share/git_deps = share/gitfile-handler.desktop 32 | 33 | [options] 34 | packages = 35 | git_deps 36 | 37 | [options.entry_points] 38 | console_scripts = 39 | git-deps = git_deps.cli:run 40 | 41 | [test] 42 | # py.test options when running `python setup.py test` 43 | addopts = tests 44 | 45 | [pytest] 46 | # Options for py.test: 47 | # Specify command line options as you would do when invoking py.test directly. 48 | # e.g. --cov-report html (or xml) for html/xml output or --junitxml junit.xml 49 | # in order to write a coverage file that can be read by Jenkins. 50 | addopts = 51 | --cov git_deps --cov-report term-missing 52 | --verbose 53 | 54 | [aliases] 55 | docs = build_sphinx 56 | 57 | [bdist_wheel] 58 | # Use this option if your package is pure-python 59 | universal = 1 60 | 61 | [build_sphinx] 62 | source_dir = docs 63 | build_dir = docs/_build 64 | 65 | [pbr] 66 | # Let pbr run sphinx-apidoc 67 | autodoc_tree_index_modules = True 68 | # autodoc_tree_excludes = ... 69 | # Let pbr itself generate the apidoc 70 | # autodoc_index_modules = True 71 | # autodoc_exclude_modules = ... 72 | # Convert warnings to errors 73 | warnerrors = True 74 | 75 | [devpi:upload] 76 | # Options for the devpi: PyPI server and packaging tool 77 | # VCS export must be deactivated since we are using setuptools-scm 78 | no-vcs = 1 79 | formats = bdist_wheel 80 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Setup file for git_deps. 5 | 6 | This file was generated with PyScaffold, a tool that easily 7 | puts up a scaffold for your new Python project. Learn more under: 8 | http://pyscaffold.readthedocs.org/ 9 | """ 10 | 11 | import sys 12 | from setuptools import setup 13 | 14 | 15 | def setup_package(): 16 | needs_sphinx = {'build_sphinx', 'upload_docs'}.intersection(sys.argv) 17 | sphinx = ['sphinx'] if needs_sphinx else [] 18 | setup( 19 | setup_requires=[ 20 | 'pyscaffold', 21 | ] + sphinx, 22 | long_description='README.md', 23 | long_description_content_type="text/markdown", 24 | use_pyscaffold=True 25 | ) 26 | 27 | 28 | if __name__ == "__main__": 29 | setup_package() 30 | -------------------------------------------------------------------------------- /share/gitfile-handler.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=gitk launcher 3 | Exec=gitfile-handler %u 4 | # Icon=emacs-icon 5 | Type=Application 6 | Terminal=false 7 | MimeType=x-scheme-handler/gitfile; 8 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | # Add requirements only needed for your unittests and during development here. 2 | # They will be installed automatically when running `python setup.py test`. 3 | # ATTENTION: Don't remove pytest-cov and pytest as they are needed. 4 | pytest-cov 5 | pytest 6 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | test-repo/ 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Dummy conftest.py for git_deps. 5 | 6 | If you don't know what this is for, just leave it empty. 7 | Read more about conftest.py under: 8 | https://pytest.org/latest/plugins.html 9 | """ 10 | from __future__ import print_function, absolute_import, division 11 | 12 | # import pytest 13 | -------------------------------------------------------------------------------- /tests/create-repo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | here=$(dirname $0) 4 | test_repo=$here/test-repo 5 | 6 | new_file () { 7 | cat < $1 8 | one 9 | two 10 | three 11 | four 12 | five 13 | six 14 | seven 15 | eight 16 | nine 17 | ten 18 | EOF 19 | 20 | git add $1 21 | git commit -m "create $1" 22 | tag $1 23 | } 24 | 25 | tag () { 26 | git tag "$@" 27 | echo -n "Hit Enter to continue ..." 28 | read 29 | } 30 | 31 | edit () { 32 | file="$1" 33 | line="$2" 34 | new="$3" 35 | sed -i "s/^$line.*/$line $new/" $file 36 | git commit -am "$file: change $line to $line $new" 37 | tag $file-$line-$new 38 | } 39 | 40 | main () { 41 | rm -rf $test_repo 42 | mkdir $test_repo 43 | cd $test_repo 44 | 45 | git init 46 | git config user.email git-test@fake.address 47 | 48 | # Start with two independently committed files 49 | for f in file-{a,b}; do 50 | new_file $f 51 | done 52 | 53 | # Now start making changes 54 | edit file-a three foo # depends on file-a tag 55 | edit file-b three bar # depends on file-b tag 56 | 57 | # Change non-overlapping part of previously changed file 58 | edit file-a eight foo # depends on file-a tag 59 | 60 | # Change previously changed line 61 | edit file-a three baz # depends on file-a-three-a tag 62 | } 63 | 64 | feature () { 65 | cd $test_repo 66 | 67 | # Start a feature branch 68 | git checkout -b feature file-b-three-bar 69 | new_file file-c 70 | edit file-c four foo 71 | edit file-c ten qux 72 | 73 | git checkout master 74 | } 75 | 76 | # For demonstrating a backporting use-case 77 | port () { 78 | cd $test_repo 79 | 80 | # Add some more commits to master 81 | edit file-b four qux 82 | edit file-a two wibble 83 | edit file-a nine wobble 84 | 85 | # Start a stable branch 86 | git checkout -b stable file-a-three-foo 87 | edit file-a three blah 88 | 89 | git checkout master 90 | } 91 | 92 | case "$1" in 93 | feature) 94 | feature 95 | ;; 96 | port) 97 | main 98 | port 99 | ;; 100 | *) 101 | main 102 | ;; 103 | esac 104 | 105 | exit 0 106 | -------------------------------------------------------------------------------- /tests/expected_outputs/deps_1ba7ad5: -------------------------------------------------------------------------------- 1 | 2bd9ea0f03a644cc3f5e7824d9ad0979ccdf94dc 2 | 2c9d23b0291157eb1096384ff76e0122747b9bdf 3 | c15f0364bf0364b8123b370b78b6d6ac8bf6f779 4 | -------------------------------------------------------------------------------- /tests/expected_outputs/deps_4f27a1e: -------------------------------------------------------------------------------- 1 | 3374b8419a45d91d3c0631be11c8cf893b272217 2 | 3a1dd42fd6114a634ba7cf037ce61e2aee76db73 3 | 5ec5ccbdff508014c61ae9d18f3366a15c0f2689 4 | 80c247fd21a1e7f476d1c8ba289498e216eff3dc 5 | b144bfd5feb327ef7ce0c26bbfb6f4da573abfe5 6 | b1967573e81a8100a4cc778936de0ba0a8a8f5cb 7 | f7bf058439fd7499aad7a10418a9f516e6949fbc 8 | -------------------------------------------------------------------------------- /tests/expected_outputs/deps_b196757: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/expected_outputs/recursive_deps_4f27a1e: -------------------------------------------------------------------------------- 1 | 2a05400e232e14f0d4c1cbfb548a0871ea57bd44 3374b8419a45d91d3c0631be11c8cf893b272217 2 | 2a05400e232e14f0d4c1cbfb548a0871ea57bd44 b144bfd5feb327ef7ce0c26bbfb6f4da573abfe5 3 | 2ebcb2b6081e32e9a463519525bd432287b24520 3a1dd42fd6114a634ba7cf037ce61e2aee76db73 4 | 2ebcb2b6081e32e9a463519525bd432287b24520 acc24a404d82061bbc6db5afb146d83bf131830b 5 | 3374b8419a45d91d3c0631be11c8cf893b272217 3a1dd42fd6114a634ba7cf037ce61e2aee76db73 6 | 3374b8419a45d91d3c0631be11c8cf893b272217 b144bfd5feb327ef7ce0c26bbfb6f4da573abfe5 7 | 3374b8419a45d91d3c0631be11c8cf893b272217 b1967573e81a8100a4cc778936de0ba0a8a8f5cb 8 | 3a1dd42fd6114a634ba7cf037ce61e2aee76db73 b144bfd5feb327ef7ce0c26bbfb6f4da573abfe5 9 | 3a1dd42fd6114a634ba7cf037ce61e2aee76db73 b1967573e81a8100a4cc778936de0ba0a8a8f5cb 10 | 4f27a1ee2b5fd63a58311a20e2aed0a24eda8da2 3374b8419a45d91d3c0631be11c8cf893b272217 11 | 4f27a1ee2b5fd63a58311a20e2aed0a24eda8da2 3a1dd42fd6114a634ba7cf037ce61e2aee76db73 12 | 4f27a1ee2b5fd63a58311a20e2aed0a24eda8da2 5ec5ccbdff508014c61ae9d18f3366a15c0f2689 13 | 4f27a1ee2b5fd63a58311a20e2aed0a24eda8da2 80c247fd21a1e7f476d1c8ba289498e216eff3dc 14 | 4f27a1ee2b5fd63a58311a20e2aed0a24eda8da2 b144bfd5feb327ef7ce0c26bbfb6f4da573abfe5 15 | 4f27a1ee2b5fd63a58311a20e2aed0a24eda8da2 b1967573e81a8100a4cc778936de0ba0a8a8f5cb 16 | 4f27a1ee2b5fd63a58311a20e2aed0a24eda8da2 f7bf058439fd7499aad7a10418a9f516e6949fbc 17 | 5ec5ccbdff508014c61ae9d18f3366a15c0f2689 3374b8419a45d91d3c0631be11c8cf893b272217 18 | 5ec5ccbdff508014c61ae9d18f3366a15c0f2689 b144bfd5feb327ef7ce0c26bbfb6f4da573abfe5 19 | 80c247fd21a1e7f476d1c8ba289498e216eff3dc 2ebcb2b6081e32e9a463519525bd432287b24520 20 | 80c247fd21a1e7f476d1c8ba289498e216eff3dc 3a1dd42fd6114a634ba7cf037ce61e2aee76db73 21 | 80c247fd21a1e7f476d1c8ba289498e216eff3dc acc24a404d82061bbc6db5afb146d83bf131830b 22 | 80c247fd21a1e7f476d1c8ba289498e216eff3dc b1967573e81a8100a4cc778936de0ba0a8a8f5cb 23 | acc24a404d82061bbc6db5afb146d83bf131830b 3a1dd42fd6114a634ba7cf037ce61e2aee76db73 24 | acc24a404d82061bbc6db5afb146d83bf131830b b144bfd5feb327ef7ce0c26bbfb6f4da573abfe5 25 | b144bfd5feb327ef7ce0c26bbfb6f4da573abfe5 b1967573e81a8100a4cc778936de0ba0a8a8f5cb 26 | f7bf058439fd7499aad7a10418a9f516e6949fbc 2a05400e232e14f0d4c1cbfb548a0871ea57bd44 27 | f7bf058439fd7499aad7a10418a9f516e6949fbc 80c247fd21a1e7f476d1c8ba289498e216eff3dc 28 | f7bf058439fd7499aad7a10418a9f516e6949fbc b144bfd5feb327ef7ce0c26bbfb6f4da573abfe5 29 | f7bf058439fd7499aad7a10418a9f516e6949fbc b1967573e81a8100a4cc778936de0ba0a8a8f5cb 30 | -------------------------------------------------------------------------------- /tests/self_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eo pipefail 4 | 5 | # Collection of tests consisting in running `git-deps` on its own repository. 6 | # The expected outputs of various commands are stored in the `expected_outputs` subdirectory. 7 | 8 | # Because git-deps can only be run from the repository's root, this script should be run at 9 | # the root of a clone of git-deps. The clone should not be shallow, so that all refs can be accessed. 10 | 11 | echo "Running test suite" 12 | 13 | echo "* Dependencies of 4f27a1e, a regular commit" 14 | git-deps 4f27a1e^! | sort | diff tests/expected_outputs/deps_4f27a1e - 15 | 16 | echo "* Same, but via pygit2's blame algorithm" 17 | git-deps --pygit2-blame 4f27a1e^! | sort | diff tests/expected_outputs/deps_4f27a1e - 18 | 19 | echo "* Dependencies of 1ba7ad5, a merge commit" 20 | git-deps 1ba7ad5^! | sort | diff tests/expected_outputs/deps_1ba7ad5 - 21 | 22 | echo "* Same, but via pygit2's blame algorithm" 23 | git-deps --pygit2-blame 1ba7ad5^! | sort | diff tests/expected_outputs/deps_1ba7ad5 - 24 | 25 | echo "* Dependencies of the root commit" 26 | git-deps b196757^! | sort | diff tests/expected_outputs/deps_b196757 - 27 | 28 | echo "* Same, but via pygit2's blame algorithm" 29 | git-deps --pygit2-blame b196757^! | sort | diff tests/expected_outputs/deps_b196757 - 30 | 31 | echo "* Recursive dependencies of a4f27a1e, a regular commit" 32 | git-deps -r 4f27a1e^! | sort | diff tests/expected_outputs/recursive_deps_4f27a1e - 33 | 34 | echo "All tests passed!" 35 | -------------------------------------------------------------------------------- /tests/test_GitUtils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # import pytest 5 | 6 | from git_deps.gitutils import GitUtils 7 | 8 | 9 | def test_abbreviate_sha1(): 10 | sha1 = GitUtils.abbreviate_sha1("HEAD") 11 | assert len(sha1) == 7 12 | -------------------------------------------------------------------------------- /tests/test_blame.py: -------------------------------------------------------------------------------- 1 | 2 | from git_deps.blame import blame_via_subprocess, BlameHunk, GitRef 3 | 4 | def test_blame_via_subprocess(): 5 | hunks = list(blame_via_subprocess( 6 | 'INSTALL.md', 7 | '04f5c095d4eccf5808db6dbf90c31a535f7f371c', 8 | 12, 4)) 9 | 10 | expected_hunks = [ 11 | BlameHunk( 12 | GitRef('6e23a48f888a355ad7e101c797ce1b66c4b7b86a'), 13 | orig_start_line_number=12, 14 | final_start_line_number=12, 15 | lines_in_hunk=2), 16 | BlameHunk( 17 | GitRef('2c9d23b0291157eb1096384ff76e0122747b9bdf'), 18 | orig_start_line_number=10, 19 | final_start_line_number=14, 20 | lines_in_hunk=2) 21 | ] 22 | 23 | assert hunks == expected_hunks 24 | 25 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox configuration file 2 | # Read more under https://tox.readthedocs.org/ 3 | 4 | [tox] 5 | minversion = 1.8 6 | envlist = py37,py38,flake8 7 | skip_missing_interpreters = True 8 | 9 | [testenv] 10 | usedevelop = True 11 | changedir = tests 12 | commands = 13 | py.test {posargs} 14 | deps = 15 | pytest 16 | -r{toxinidir}/requirements.txt 17 | 18 | [testenv:flake8] 19 | skip_install = True 20 | changedir = {toxinidir} 21 | deps = flake8 22 | commands = flake8 setup.py git_deps tests 23 | 24 | # Options for pytest 25 | [pytest] 26 | addopts = -rsxXf 27 | 28 | [testenv:sdist] 29 | usedevelop = False 30 | --------------------------------------------------------------------------------