diff --git a/.gitignore b/.gitignore index 3bd4eec0..51619dc8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,8 @@ env # Generated documentation docs/gen -apidocs +/apidocs +/site /*.html *.rst docs/*.png diff --git a/.pep257 b/.pep257 new file mode 100644 index 00000000..229f6b24 --- /dev/null +++ b/.pep257 @@ -0,0 +1,5 @@ +[pep257] + +# D10*: docstring missing (checked by PyLint) +# D202: No blank lines allowed *after* function docstring (personal preference) +add-ignore = D102,D103,D105,D202 diff --git a/.travis.yml b/.travis.yml index 5713fa79..93f54e4e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,4 @@ -sudo: required +sudo: false language: python python: @@ -6,17 +6,17 @@ python: - 3.4 - 3.5 -cache: pip +cache: + pip: true + directories: + - env -before_install: -- sudo add-apt-repository ppa:git-core/ppa -y -- sudo apt-get update env: global: - RANDOM_SEED=12345 + - secure: "UnyHAJ/T6eI/6vaXXKsZgs2XqBib06DubRqMVegy1nMNBjFsmyZ3tU+22gW2NWAGzukwC3GXWsxkN+7sTlTcTldFBvN9onzC1oVDfQrcAUjvSOK02OZ4tepVq5umsDUxGpRqhWB7LgEv0zM6DHzj6k+yaPBtcJg6NFhpScQfHyY=" install: -- sudo apt-get install git; git --version - pip install coveralls scrutinizer-ocular before_script: @@ -30,6 +30,24 @@ after_success: - coveralls - ocular +after_script: > + echo $TRAVIS_BRANCH; echo $TRAVIS_PULL_REQUEST; + if [[ $TRAVIS_BRANCH == 'develop' && $TRAVIS_PULL_REQUEST == 'false' ]]; then + # Generate site + make mkdocs ; + # Configure Git with Travis CI information + git config --global user.email "travis@travis-ci.org" ; + git config --global user.name "travis-ci" ; + # Delete the current repository + rm -rf .git ; + # Rebuild the repository from the generated files and push to GitHub pages + cd site ; + git init ; + git add . ; + git commit -m "Deploy Travis CI build $TRAVIS_BUILD_NUMBER to GitHub pages" ; + git push -f https://${GH_TOKEN}@github.com/${TRAVIS_REPO_SLUG} master:gh-pages ; + fi + notifications: email: on_success: never diff --git a/CHANGES.md b/CHANGES.md index 0c116560..4f33fb3f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,17 @@ Revision History ================ +0.5 (2015/10/20) +---------------- + +- Added Git plugin support via: `git deps`. +- Removed '--no-clean' option (now the default) on 'install' and 'update'. +- Added '--clean' option to delete ignored files on 'install' and 'update'. +- Switched to 'install' rather than 'update' of nested dependencies. +- Added '--all' option on 'update' to update all nested dependencies. +- Disabled warnings when running 'install' without locked sources. +- Added '--no-lock' option to disable version recording. + 0.4.2 (2015/10/18) ------------------ diff --git a/LICENSE.txt b/LICENSE.md similarity index 92% rename from LICENSE.txt rename to LICENSE.md index cfafbaf9..b994447d 100644 --- a/LICENSE.txt +++ b/LICENSE.md @@ -1,6 +1,8 @@ -The MIT License (MIT) +# License -Copyright (c) 2015 Jace Browning +**The MIT License (MIT)** + +Copyright © 2015, Jace Browning Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index 419156db..d413af95 100644 --- a/Makefile +++ b/Makefile @@ -64,6 +64,7 @@ NOSE := $(BIN)/nosetests PYTEST := $(BIN)/py.test COVERAGE := $(BIN)/coverage SNIFFER := $(BIN)/sniffer +MKDOCS := $(BIN)/mkdocs # Flags for PHONY targets DEPENDS_CI_FLAG := $(ENV)/.depends-ci @@ -77,10 +78,10 @@ ALL_FLAG := $(ENV)/.all all: depends doc $(ALL_FLAG) $(ALL_FLAG): $(SOURCES) $(MAKE) check - touch $(ALL_FLAG) # flag to indicate all setup steps were successful + @ touch $(ALL_FLAG) # flag to indicate all setup steps were successful .PHONY: ci -ci: check test tests +ci: mkdocs check test tests .PHONY: watch watch: depends-dev .clean-test @@ -107,7 +108,7 @@ depends: depends-ci depends-dev .PHONY: depends-ci depends-ci: env Makefile $(DEPENDS_CI_FLAG) $(DEPENDS_CI_FLAG): Makefile - $(PIP) install --upgrade pep8 pep257==0.6 pylint coverage pytest pytest-cov pytest-random pytest-runfailed + $(PIP) install --upgrade pep8 pep257 pylint coverage pytest pytest-cov pytest-random pytest-runfailed mkdocs @ touch $(DEPENDS_CI_FLAG) # flag to indicate dependencies are installed .PHONY: depends-dev @@ -125,8 +126,10 @@ endif # Documentation ################################################################ +URL := "git-dependency-manager.info" + .PHONY: doc -doc: readme verify-readme apidocs uml +doc: readme verify-readme uml apidocs mkdocs .PHONY: readme readme: depends-dev README-github.html README-pypi.html @@ -143,11 +146,6 @@ $(DOCS_FLAG): README.rst $(PYTHON) setup.py check --restructuredtext --strict --metadata @ touch $(DOCS_FLAG) # flag to indicate README has been checked -.PHONY: apidocs -apidocs: depends-dev apidocs/$(PACKAGE)/index.html -apidocs/$(PACKAGE)/index.html: $(SOURCES) - $(PDOC) --html --overwrite $(PACKAGE) --html-dir apidocs - .PHONY: uml uml: depends-dev docs/*.png docs/*.png: $(SOURCES) @@ -155,8 +153,25 @@ docs/*.png: $(SOURCES) - mv -f classes_$(PACKAGE).png docs/classes.png - mv -f packages_$(PACKAGE).png docs/packages.png +.PHONY: apidocs +apidocs: depends-dev apidocs/$(PACKAGE)/index.html +apidocs/$(PACKAGE)/index.html: $(SOURCES) + $(PDOC) --html --overwrite $(PACKAGE) --html-dir apidocs + +.PHONY: mkdocs +mkdocs: depends-ci site/index.html +site/index.html: mkdocs.yml docs/*.md + $(MKDOCS) build --clean --strict + echo $(URL) > site/CNAME + +.PHONY: mkdocs-live +mkdocs-live: mkdocs + eval "sleep 3; open http://127.0.0.1:8000" & + $(MKDOCS) serve + .PHONY: read read: doc + $(OPEN) site/index.html $(OPEN) apidocs/$(PACKAGE)/index.html $(OPEN) README-pypi.html $(OPEN) README-github.html @@ -172,10 +187,7 @@ pep8: depends-ci .PHONY: pep257 pep257: depends-ci -# D102/D103: docstring missing (checked by PyLint) -# D202: No blank lines allowed *after* function docstring (personal preference) -# D203: 1 blank line required before class (deprecated warning) - $(PEP257) $(PACKAGE) tests --ignore=D102,D103,D202,D203 + $(PEP257) $(PACKAGE) tests .PHONY: pylint pylint: depends-ci @@ -258,7 +270,7 @@ clean-all: clean clean-env .clean-workspace .PHONY: .clean-doc .clean-doc: - rm -rf README.rst apidocs *.html docs/*.png + rm -rf README.rst apidocs *.html docs/*.png site .PHONY: .clean-test .clean-test: diff --git a/README.md b/README.md index 36790245..6d65200f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Build Status](http://img.shields.io/travis/jacebrowning/gdm/master.svg)](https://travis-ci.org/jacebrowning/gdm) +[![Build Status](https://travis-ci.org/jacebrowning/gdm.svg?branch=develop)](https://travis-ci.org/jacebrowning/gdm) [![Coverage Status](http://img.shields.io/coveralls/jacebrowning/gdm/master.svg)](https://coveralls.io/r/jacebrowning/gdm) [![Scrutinizer Code Quality](http://img.shields.io/scrutinizer/g/jacebrowning/gdm.svg)](https://scrutinizer-ci.com/g/jacebrowning/gdm/?branch=master) [![PyPI Version](http://img.shields.io/pypi/v/GDM.svg)](https://pypi.python.org/pypi/GDM) @@ -7,6 +7,8 @@ Getting Started =============== +Git Dependency Manager (GDM) is a language-agnostic "dependency manager" using Git. It aims to serve as a submodules replacement and provides advanced options for managing versions of nested Git repositories. + Requirements ------------ @@ -19,13 +21,13 @@ Installation GDM can be installed with pip: -``` +```sh $ pip3 install gdm ``` or directly from the source code: -``` +```sh $ git clone https://github.com/jacebrowning/gdm.git $ cd gdm $ python3 setup.py install @@ -50,7 +52,7 @@ sources: Ignore GDM's dependency storage location: -``` +```sh $ echo .gdm >> .gitignore ``` @@ -59,7 +61,7 @@ Basic Usage See the available commands: -``` +```sh $ gdm --help ``` @@ -68,7 +70,7 @@ Updating Dependencies Get the latest versions of all dependencies: -``` +```sh $ gdm update ``` @@ -92,13 +94,13 @@ Restoring Previous Versions Display the specific revisions that are currently installed: -``` +```sh $ gdm list ``` Reinstall these specific versions at a later time: -``` +```sh $ gdm install ``` @@ -107,6 +109,11 @@ Deleting Dependencies Remove all installed dependencies: -``` +```sh $ gdm uninstall ``` + +Advanced Options +================ + +See the full documentation at http://git-dependency-manager.info/. diff --git a/docs/.gitignore b/docs/.gitignore index 65bb7cbe..e33609d2 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1 +1 @@ -# remove this placeholder after adding files +*.png diff --git a/docs/.gitkeep b/docs/CHANGES.md similarity index 100% rename from docs/.gitkeep rename to docs/CHANGES.md diff --git a/docs/about/changes.md b/docs/about/changes.md new file mode 120000 index 00000000..8980b4a7 --- /dev/null +++ b/docs/about/changes.md @@ -0,0 +1 @@ +../../CHANGES.md \ No newline at end of file diff --git a/docs/about/contributing.md b/docs/about/contributing.md new file mode 120000 index 00000000..f939e75f --- /dev/null +++ b/docs/about/contributing.md @@ -0,0 +1 @@ +../../CONTRIBUTING.md \ No newline at end of file diff --git a/docs/about/license.md b/docs/about/license.md new file mode 120000 index 00000000..f0608a63 --- /dev/null +++ b/docs/about/license.md @@ -0,0 +1 @@ +../../LICENSE.md \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..2d2c6589 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,76 @@ +# Git Dependency Manager + +Git Dependency Manager (GDM) is a language-agnostic "dependency manager" using Git. It aims to serve as a submodules replacement and provides advanced options for managing versions of nested Git repositories. + +[![Build Status](https://travis-ci.org/jacebrowning/gdm.svg?branch=develop)](https://travis-ci.org/jacebrowning/gdm) +[![Coverage Status](http://img.shields.io/coveralls/jacebrowning/gdm/master.svg)](https://coveralls.io/r/jacebrowning/gdm) +[![Scrutinizer Code Quality](http://img.shields.io/scrutinizer/g/jacebrowning/gdm.svg)](https://scrutinizer-ci.com/g/jacebrowning/gdm/?branch=master) +[![PyPI Version](http://img.shields.io/pypi/v/GDM.svg)](https://pypi.python.org/pypi/GDM) +[![PyPI Downloads](http://img.shields.io/pypi/dm/GDM.svg)](https://pypi.python.org/pypi/GDM) + +## Requirements + +* Python 3.3+ +* Latest version of Git (with [stored credentials](http://stackoverflow.com/questions/7773181)) +* OSX/Linux (with a decent shell for Git) + +## Installation + +GDM can be installed with pip: + +```sh +$ pip3 install gdm +``` + +or directly from the source code: + +```sh +$ git clone https://github.com/jacebrowning/gdm.git +$ cd gdm +$ python3 setup.py install +``` + +## Setup + +Create a GDM configuration file (`gdm.yml` or `.gdm.yml`) in the root of your working tree: + +```yaml +location: .gdm +sources: +- repo: https://github.com/kstenerud/iOS-Universal-Framework + dir: framework + rev: Mk5-end-of-life +- repo: https://github.com/jonreid/XcodeCoverage + dir: coverage + rev: master + link: Tools/XcodeCoverage +``` + +Ignore GDM's dependency storage location: + +```sh +$ echo .gdm >> .gitignore +``` + +Basic Usage +----------- + +Get all dependencies: + +```sh +$ gdm install +``` + +which will essentially: + +1. create a working tree at _root_/`location`/`dir` +2. fetch from `repo` and checkout the specified `rev` +3. symbolically link each `location`/`dir` from _root_/`link` (if specified) +4. repeat for all nested working trees containing a configuration file + +where `rev` can be: + +* all or part of a commit SHA: `123def` +* a tag: `v1.0` +* a branch: `master` +* a `rev-parse` date: `'develop@{2015-06-18 10:30:59}'` diff --git a/docs/interfaces/api.md b/docs/interfaces/api.md new file mode 100644 index 00000000..59163854 --- /dev/null +++ b/docs/interfaces/api.md @@ -0,0 +1,59 @@ +# Package API + +All of the [command-line interface](cli.md) functionality is available from the Python package by importing `gdm`. + +## Install + +To clone/checkout the specified dependencies, call: + +```python +gdm.install(root=None, force=False, clean=True) +``` + +where optional arguments: + +- `root`: specifies the path to the root working tree +- `force`: indicates that uncommitted changes can be overwritten +- `clean`: causes all untracked files to be deleted from dependencies + +## Update + +If any of the dependencies track a branch (rather than a specific commit), the current upstream version of that branch can be checked out by calling: + +```python +gdm.update(root=None, recurse=False, force=False, clean=True, lock=True) +``` + +where optional arguments: + +- `root`: specifies the path to the root working tree +- `recurse`: indicates that nested dependencies should also be updated +- `force`: indicates that uncommitted changes can be overwritten +- `clean`: causes all untracked files to be deleted from dependencies +- `lock`: causes the actual dependency versions to be recorded for future installs + +## List + +To display the currently checked out dependencies, call: + +```python +gdm.list(root=None, allow_dirty=True) +``` + +where optional arguments: + +- `root`: specifies the path to the root working tree +- `allow_dirty`: causes uncommitted changes to be ignored + +## Uninstall + +To delete all source dependencies, call: + +```python +gdm.uninstall(root=None, force=False) +``` + +where optional arguments: + +- `root`: specifies the path to the root working tree +- `force`: indicates that uncommitted changes can be overwritten diff --git a/docs/interfaces/cli.md b/docs/interfaces/cli.md new file mode 100644 index 00000000..0a4fbfec --- /dev/null +++ b/docs/interfaces/cli.md @@ -0,0 +1,77 @@ +# Command-line Interface + +After setting up GDM with a [configuration file](../index.md#setup), various commands can be run to manage these Git-controlled source dependencies. + +## Install + +To clone/checkout the specified dependencies, run: + +```sh +gdm install +``` + +Delete all untracked files in dependencies by instead running: + +```sh +gdm install --clean +``` + +GDM will exit with an error if there are any uncommitted changes in dependencies. To overwrite all changes, run: + +```sh +gdm install --force +``` + +## Update + +If any of the dependencies track a branch (rather than a specific commit), the current upstream version of that branch can be checked out by running: + +```sh +gdm update +``` + +This will also record the exact versions that were checked out. Disable this behavior by instead running: + +```sh +gdm update --no-lock +``` + +Or, to additionally get the latest versions of all nested dependencies, run: + +```sh +gdm update --all +``` + +To restore the exact versions previously checked out, run: + +```sh +gdm install +``` + +## List + +To display the currently checked out dependencies, run: + +```sh +gdm list +``` + +Exit with an error if there are any uncommitted changes by instead running: + +```sh +gdm list --no-dirty +``` + +## Uninstall + +To delete all source dependencies, run: + +```sh +gdm uninstall +``` + +If any dependencies contain uncommitted changes, instead run: + +```sh +gdm uninstall --force +``` diff --git a/docs/interfaces/plugin.md b/docs/interfaces/plugin.md new file mode 100644 index 00000000..2cabf2de --- /dev/null +++ b/docs/interfaces/plugin.md @@ -0,0 +1,73 @@ +# Git Plugin + +GDM offers a simplified version of the [command-line interface](cli.md) in the form of a plugin for Git. + +## Install + +To clone/checkout the specified dependencies, run: + +```sh +git deps +``` + +Delete all untracked files in dependencies by instead running: + +```sh +git deps --clean +``` + +Git will exit with an error if there are any uncommitted changes in dependencies. To overwrite all changes, run: + +```sh +git deps --force +``` + +## Update + +If any of the dependencies track a branch (rather than a specific commit), the current upstream version of that branch can be checked out by running: + +```sh +git deps --update +``` + +This will also record the exact versions that were checked out. Disable this behavior by instead running: + +```sh +git deps --update --no-lock +``` + +Or, to additionally get the latest versions of all nested dependencies, run: + +```sh +git deps --update --all +``` + +To restore the exact versions previously checked out, run: + +```sh +git deps +``` + +## List + +To display the currently checked out dependencies, run: + +```sh +git deps --list +``` + +## Uninstall + +To delete all source dependencies, run: + +```sh +git deps --uninstall +``` + +If any dependencies contain uncommitted changes, instead run: + +```sh +git deps --uninstall --force +``` + + diff --git a/docs/use-cases/branch-tracking.md b/docs/use-cases/branch-tracking.md new file mode 100644 index 00000000..5ad7b060 --- /dev/null +++ b/docs/use-cases/branch-tracking.md @@ -0,0 +1,46 @@ +# Tracking Branches in Dependencies + +One common use case of GDM is to track versions of related product sub-components such as a web app that depends on an API. + +## Sample Configuration + +A web app's `gdm.yml` might look something like: + +```yaml +location: gdm_sources +sources: +- dir: api + link: '' + repo: https://github.com/example/api + rev: develop +sources_locked: +- dir: api + link: '' + repo: https://github.com/example/api + rev: b2730855c9efaaa7448b25b82e5a4363785c83ed +``` + +with a working tree that results in something like: + +```sh +package.json +node_modules + +gdm.yml +gdm_sources/api # dependency @ b27308 + +app +tests +``` + +## Understanding Locked Sources + +In the configuration file, the `sources_locked` section identifies that commit `b27308` of the API was last used to test this web app -- the last time `$ gdm update` was run. + +The `sources` section identifies that the `develop` branch should be used when checking out a new version of the API. + +## Development Workflow + +1. Run `$ gdm install` during continuous integration to test the web app against a known working API +2. Run `$ gdm update` locally to determine if newer versions of the API will break the web app +3. When both components are working together, commit `gdm.yml` diff --git a/docs/use-cases/linked-features.md b/docs/use-cases/linked-features.md new file mode 100644 index 00000000..5c0d6986 --- /dev/null +++ b/docs/use-cases/linked-features.md @@ -0,0 +1,31 @@ +# Linking Related Feature Branches + + +Another use case of GDM is to test experimental versions of related product sub-components. In the [web app + API example](branch-tracking.md), a new feature might require changes in both the API and web app. + +## Custom Locked Sources + +By manually modifying the `sources_locked` section, a particular version of the API can be checked out to help finish the complete feature in the web app: + +```yaml +location: gdm_sources +sources: +- dir: api + link: '' + repo: https://github.com/example/api + rev: develop +sources_locked: +- dir: api + link: '' + repo: https://github.com/example/api + rev: feature/authenticate-with-github # related feature branch in the API +``` + +If this modified `gdm.yml` is committed to a corresponding feature branch in the web app, others will be able to create a similar working tree to collaborate on the feature. + +## Development Workflow + +1. Run `$ gdm install` during continuous integration and locally to test the web app against the proposed API changes +2. Commit `gdm.yml` to share this feature branch with others +3. When the feature is complete, merge the API feature branch first +4. Run `$ gdm update` to reset `gdm.yml` back to a tracking a specific commit diff --git a/docs/use-cases/submodules.md b/docs/use-cases/submodules.md new file mode 100644 index 00000000..9080dd2a --- /dev/null +++ b/docs/use-cases/submodules.md @@ -0,0 +1,42 @@ +# Replacing Git Submodules + +While Git [submodules](http://git-scm.com/docs/git-submodule) are an obvious choice to include a particular version of another repository in yours, they end up being far less flexible when one needs to track branches or frequently switch between multiple versions of dependencies. + +## An Existing Submodule + +When managing a single dependency using submodules, there will be two items in your working tree with special meaning. The `.gitmodules` file, which contains submodule configuration, and semi-ignored directory containing the checked out dependency: + +```sh +/vendor/my_dependency # submodule at: a5fe3d +``` + +Using Git in the outer working tree will essentially ignore the contents of the nested working tree, but will still complain if there are changes locally or the submodule's origin has changes. + +## Mimicking Submodules + +To get the same behavior using GDM, first delete the `.gitmodules` file and create a new `.gdm.yml`: + +```yaml +location: .gdm +sources: +- repo: + dir: my_dependency + rev: a5fe3d + link: vendor/my_depenendy +``` + +Add `.gdm` to your `.gitignore` file and overwrite the old submodule location by running: + +```sh +gdm install --force +``` + +Now `/vendor/my_dependency` will be a symbolic link that points to an ignored working tree of `my_dependency` at revision `a5fe3d`. + +### Getting Dependencies + +In other working trees, simply run `$ gdm install` to check out the source dependencies of your project. + +### Modifying Dependencies + +To include a different version of a dependency, modify the `rev` value in the GDM configuration file. diff --git a/gdm/__init__.py b/gdm/__init__.py index e57f2a4e..47dfc459 100644 --- a/gdm/__init__.py +++ b/gdm/__init__.py @@ -3,9 +3,10 @@ import sys __project__ = 'GDM' -__version__ = '0.4.2' +__version__ = '0.5' CLI = 'gdm' +PLUGIN = 'deps' VERSION = __project__ + ' v' + __version__ DESCRIPTION = "A language-agnostic \"dependency manager\" using Git." diff --git a/gdm/cli.py b/gdm/cli.py index eeb3638e..8d5dbfaa 100644 --- a/gdm/cli.py +++ b/gdm/cli.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 """Command-line interface.""" @@ -41,24 +41,30 @@ def main(args=None, function=None): help=info, **shared) sub.add_argument('-f', '--force', action='store_true', help="overwrite uncommitted changes in dependencies") - sub.add_argument('-C', '--no-clean', action='store_false', dest='clean', + sub.add_argument('-c', '--clean', action='store_true', help="keep ignored files in dependencies") # Update parser - info = "update all dependencies to the latest versions" + info = "update dependencies to the latest versions" sub = subs.add_parser('update', description=info.capitalize() + '.', help=info, **shared) - # TODO: share these with 'install' + # TODO: share force and clean with 'install' sub.add_argument('-f', '--force', action='store_true', help="overwrite uncommitted changes in dependencies") - sub.add_argument('-C', '--no-clean', action='store_false', dest='clean', + sub.add_argument('-c', '--clean', action='store_true', help="keep ignored files in dependencies") + sub.add_argument('-a', '--all', action='store_true', dest='recurse', + help="update all nested dependencies, recursively") + sub.add_argument('-L', '--no-lock', + action='store_false', dest='lock', default=True, + help="skip recording of versions for later reinstall") # Display parser info = "display the current version of each dependency" sub = subs.add_parser('list', description=info.capitalize() + '.', help=info, **shared) sub.add_argument('-D', '--no-dirty', action='store_false', + dest='allow_dirty', help="fail if a source has uncommitted changes") # Uninstall parser @@ -70,28 +76,40 @@ def main(args=None, function=None): # Parse arguments args = parser.parse_args(args=args) + + # Configure logging + common.configure_logging(args.verbose) + + # Run the program + function, kwargs, exit_msg = _get_command(function, args) + if function is None: + parser.print_help() + sys.exit(1) + _run_command(function, kwargs, exit_msg) + + +def _get_command(function, args): kwargs = dict(root=args.root) exit_msg = "" + if args.command in ('install', 'update'): function = getattr(commands, args.command) - kwargs.update(dict(force=args.force, - clean=args.clean)) + kwargs.update(force=args.force, clean=args.clean) + if args.command == 'update': + kwargs.update(recurse=args.recurse, lock=args.lock) exit_msg = "\n" + "Run again with '--force' to overwrite" + elif args.command == 'list': + function = commands.display + kwargs.update(dict(allow_dirty=args.allow_dirty)) elif args.command == 'uninstall': function = commands.delete - kwargs.update(dict(force=args.force)) + kwargs.update(force=args.force) exit_msg = "\n" + "Run again with '--force' to ignore" - elif args.command == 'list': - function = commands.display - kwargs.update(dict(allow_dirty=args.no_dirty)) - if function is None: - parser.print_help() - sys.exit(1) - # Configure logging - common.configure_logging(args.verbose) + return function, kwargs, exit_msg - # Run the program + +def _run_command(function, kwargs, exit_msg): success = False try: log.debug("running command...") @@ -102,6 +120,7 @@ def main(args=None, function=None): log.exception(msg) else: log.debug(msg) + exit_msg = "" except RuntimeError as exc: exit_msg = str(exc) + exit_msg else: diff --git a/gdm/commands.py b/gdm/commands.py index f48a9620..933e4eac 100644 --- a/gdm/commands.py +++ b/gdm/commands.py @@ -26,9 +26,11 @@ def install(root=None, force=False, clean=True): return count -def update(root=None, force=False, clean=True): +def update(root=None, recurse=False, force=False, clean=True, lock=True): """Update dependencies for a project.""" - log.info("%supdating dependencies...", 'force-' if force else '') + log.info("%supdating dependencies%s...", + 'force-' if force else '', + ', recursively' if recurse else '') count = None root = _find_root(root) @@ -37,11 +39,12 @@ def update(root=None, force=False, clean=True): if config: common.show("Updating dependencies...", log=False) common.show() - count = config.install_deps(force=force, clean=clean) + count = config.install_deps(recurse=recurse, force=force, clean=clean) common.dedent(level=0) - common.show("Recording installed versions...", log=False) - common.show() - config.lock_deps() + if lock: + common.show("Recording installed versions...", log=False) + common.show() + config.lock_deps() _display_result("update", "updated", count) diff --git a/gdm/common.py b/gdm/common.py index c4544b07..5eeb08f0 100644 --- a/gdm/common.py +++ b/gdm/common.py @@ -14,12 +14,10 @@ class CallException(Exception): - """Exception raised when a program call has a non-zero return code.""" class WideHelpFormatter(argparse.HelpFormatter): - """Command-line help text formatter with wider help text.""" def __init__(self, *args, **kwargs): @@ -27,7 +25,6 @@ def __init__(self, *args, **kwargs): class WarningFormatter(logging.Formatter): - """Logging formatter that displays verbose formatting for WARNING+.""" def __init__(self, default_format, verbose_format, *args, **kwargs): diff --git a/gdm/config.py b/gdm/config.py index d76a2085..983cdf1e 100644 --- a/gdm/config.py +++ b/gdm/config.py @@ -17,7 +17,6 @@ @yorm.attr(rev=yorm.converters.String) @yorm.attr(link=yorm.converters.String) class Source(yorm.converters.AttributeDictionary, ShellMixin, GitMixin): - """A dictionary of `git` and `ln` arguments.""" def __init__(self, repo, dir, rev='master', link=None): # pylint: disable=W0622 @@ -117,7 +116,6 @@ def lock(self): @yorm.attr(all=Source) class Sources(yorm.converters.SortedList): - """A list of source dependencies.""" @@ -126,7 +124,6 @@ class Sources(yorm.converters.SortedList): @yorm.attr(sources_locked=Sources) @yorm.sync("{self.root}/{self.filename}") class Config(ShellMixin): - """A dictionary of dependency configuration options.""" FILENAMES = ('gdm.yml', 'gdm.yaml', '.gdm.yml', '.gdm.yaml') @@ -149,8 +146,8 @@ def location_path(self): """Get the full path to the sources location.""" return os.path.join(self.root, self.location) - def install_deps(self, force=False, clean=True, update=True): - """Get all sources, recursively.""" + def install_deps(self, update=True, recurse=False, force=False, clean=True): + """Get all sources.""" if not os.path.isdir(self.location_path): self.mkdir(self.location_path) self.cd(self.location_path) @@ -170,7 +167,10 @@ def install_deps(self, force=False, clean=True, update=True): config = load() if config: common.indent() - count += config.install_deps(force, clean, update) + count += config.install_deps(update=update and recurse, + recurse=recurse, + force=force, + clean=clean) common.dedent() self.cd(self.location_path, visible=False) @@ -227,7 +227,7 @@ def _get_sources(self, update): elif self.sources_locked: return self.sources_locked else: - log.warn("no locked sources available, installing latest...") + log.info("no locked sources available, installing latest...") return self.sources diff --git a/gdm/plugin.py b/gdm/plugin.py new file mode 100644 index 00000000..7ec14d4c --- /dev/null +++ b/gdm/plugin.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 + +"""Plugin for Git.""" + +import argparse + +from . import PLUGIN, __version__ +from . import common +from .cli import _get_command, _run_command + +PROG = 'git ' + PLUGIN +DESCRIPTION = "Use GDM (v{}) to manage source dependencies.".format(__version__) + +log = common.logger(__name__) + + +def main(args=None): + """Process command-line arguments and run the Git plugin.""" + + # Main parser + parser = argparse.ArgumentParser(prog=PROG, description=DESCRIPTION) + parser.add_argument( + '-f', '--force', action='store_true', + help="overwrite uncommitted changes in dependencies", + ) + parser.add_argument( + '-c', '--clean', action='store_true', + help="keep ignored files when updating dependencies", + ) + + # Options group + group = parser.add_mutually_exclusive_group() + shared = dict(action='store_const', dest='command') + + # Update option + group.add_argument( + '-u', '--update', const='update', + help="update dependencies to the latest versions", **shared + ) + parser.add_argument('-a', '--all', action='store_true', dest='recurse', + help="include nested dependencies when updating") + parser.add_argument('-L', '--no-lock', + action='store_false', dest='lock', default=True, + help="skip recording of versions for later reinstall") + + # Display option + group.add_argument( + '-l', '--list', const='list', + help="display the current version of each dependency", **shared + ) + + # Uninstall option + group.add_argument( + '-x', '--uninstall', const='uninstall', + help="delete all installed dependencies", **shared + ) + + # Parse arguments + args = parser.parse_args(args=args) + + # Modify arguments to match CLI interface + if not args.command: + args.command = 'install' + args.root = None + args.allow_dirty = True + + # Configure logging + common.configure_logging() + + # Run the program + function, kwargs, exit_msg = _get_command(None, args) + _run_command(function, kwargs, exit_msg) + + +if __name__ == '__main__': # pragma: no cover (manual test) + main() diff --git a/gdm/shell.py b/gdm/shell.py index 848d726b..030d84f5 100644 --- a/gdm/shell.py +++ b/gdm/shell.py @@ -35,7 +35,6 @@ def _call(name, *args, ignore=False, catch=True, capture=False): class _Base: - """Functions to call shell commands.""" @staticmethod @@ -47,7 +46,6 @@ def _call(*args, visible=True, catch=True, ignore=False, capture=False): class ShellMixin(_Base): - """Provides classes with shell utilities.""" def mkdir(self, path): @@ -67,7 +65,6 @@ def rm(self, path): class GitMixin(_Base): - """Provides classes with Git utilities.""" def git_clone(self, repo, path): @@ -81,7 +78,7 @@ def git_fetch(self, repo, rev=None): args = ['fetch', '--tags', '--force', '--prune', 'origin'] if rev: if len(rev) == 40: - pass # fetch doesn't work with SHAs + pass # fetch only works with a SHA if already present locally elif '@' in rev: pass # fetch doesn't work with rev-parse else: diff --git a/gdm/test/test_cli.py b/gdm/test/test_cli.py index dbebf65b..e4c640d2 100644 --- a/gdm/test/test_cli.py +++ b/gdm/test/test_cli.py @@ -52,44 +52,76 @@ def test_main_error(self): cli.main([], Mock(side_effect=RuntimeError)) -class TestInstallAndUpdate: +class TestInstall: - """Unit tests for the `install` and `update` commands.""" + """Unit tests for the `install` command.""" @patch('gdm.commands.install') def test_install(self, mock_install): """Verify the 'install' command can be run.""" cli.main(['install']) mock_install.assert_called_once_with(root=None, - force=False, clean=True) + force=False, + clean=False) @patch('gdm.commands.install') def test_install_root(self, mock_install): """Verify the project's root can be specified.""" cli.main(['install', '--root', 'mock/path/to/root']) mock_install.assert_called_once_with(root='mock/path/to/root', - force=False, clean=True) + force=False, + clean=False) @patch('gdm.commands.install') def test_install_force(self, mock_install): """Verify dependencies can be force-installed.""" cli.main(['install', '--force']) mock_install.assert_called_once_with(root=None, - force=True, clean=True) + force=True, + clean=False) @patch('gdm.commands.install') - def test_install_no_clean(self, mock_install): - """Verify dependency cleaning can be disabled.""" - cli.main(['install', '--no-clean']) + def test_install_clean(self, mock_install): + """Verify dependency cleaning can be enabled.""" + cli.main(['install', '--clean']) mock_install.assert_called_once_with(root=None, - force=False, clean=False) + force=False, + clean=True) + + +class TestUpdate: + + """Unit tests for the `update` command.""" @patch('gdm.commands.update') def test_update(self, mock_update): """Verify the 'update' command can be run.""" cli.main(['update']) mock_update.assert_called_once_with(root=None, - force=False, clean=True) + force=False, + clean=False, + recurse=False, + lock=True) + + @patch('gdm.commands.update') + def test_update_recursive(self, mock_update): + """Verify the 'update' command can be run recursively.""" + cli.main(['update', '--all']) + mock_update.assert_called_once_with(root=None, + force=False, + clean=False, + recurse=True, + lock=True) + + @patch('gdm.commands.update') + def test_update_no_lock(self, mock_update): + """Verify the 'update' command can be run without locking.""" + cli.main(['update', '--no-lock']) + mock_update.assert_called_once_with(root=None, + force=False, + clean=False, + recurse=False, + lock=False) class TestUninstall: diff --git a/gdm/test/test_plugin.py b/gdm/test/test_plugin.py new file mode 100644 index 00000000..245ea956 --- /dev/null +++ b/gdm/test/test_plugin.py @@ -0,0 +1,74 @@ +"""Unit tests for the 'plugin' module.""" +# pylint: disable=no-self-use + +from unittest.mock import patch, call + +from gdm import plugin + + +class TestMain: + + """Unit tests for the top-level arguments.""" + + @patch('gdm.cli.commands') + def test_install(self, mock_commands): + """Verify 'install' is the default command.""" + plugin.main([]) + + assert [ + call.install(root=None, clean=False, force=False), + call.install().__bool__(), # command status check + ] == mock_commands.mock_calls + + @patch('gdm.cli.commands') + def test_update(self, mock_commands): + """Verify 'update' can be called with cleaning.""" + plugin.main(['--update', '--clean']) + + assert [ + call.update(root=None, + clean=True, force=False, recurse=False, lock=True), + call.update().__bool__(), # command status check + ] == mock_commands.mock_calls + + @patch('gdm.cli.commands') + def test_update_recursive(self, mock_commands): + """Verify 'update' can be called recursively.""" + plugin.main(['--update', '--all']) + + assert [ + call.update(root=None, + clean=False, force=False, recurse=True, lock=True), + call.update().__bool__(), # command status check + ] == mock_commands.mock_calls + + @patch('gdm.cli.commands') + def test_update_no_lock(self, mock_commands): + """Verify 'update' can be called without locking.""" + plugin.main(['--update', '--no-lock']) + + assert [ + call.update(root=None, + clean=False, force=False, recurse=False, lock=False), + call.update().__bool__(), # command status check + ] == mock_commands.mock_calls + + @patch('gdm.cli.commands') + def test_list(self, mock_commands): + """Verify 'list' can be called.""" + plugin.main(['--list']) + + assert [ + call.display(root=None, allow_dirty=True), + call.display().__bool__(), # command status check + ] == mock_commands.mock_calls + + @patch('gdm.cli.commands') + def test_uninstall(self, mock_commands): + """Verify 'clean' can be called with force.""" + plugin.main(['--uninstall', '--force']) + + assert [ + call.delete(root=None, force=True), + call.delete().__bool__(), # command status check + ] == mock_commands.mock_calls diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..01894692 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,22 @@ +site_name: GDM +site_description: A language-agnostic "dependency manager" using Git. +site_author: Jace Browning +repo_url: https://github.com/jacebrowning/gdm +google_analytics: ['UA-6468614-11', 'git-dependency-manager.info'] + +theme: mkdocs + +pages: +- Home: 'index.md' +- Interfaces: + - Command Line: interfaces/cli.md + - Git Plugin: interfaces/plugin.md + - Package API: interfaces/api.md +- 'Use Cases': + - 'Replacing Submodules': 'use-cases/submodules.md' + - 'Tracking Branches': 'use-cases/branch-tracking.md' + - 'Linking Feature Branches': 'use-cases/linked-features.md' +- About: + - 'Release Notes': 'about/changes.md' + - Contributing: 'about/contributing.md' + - License: 'about/license.md' diff --git a/requirements.txt b/requirements.txt index a2fee632..0f28b468 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -YORM ~= 0.4 +YORM ~= 0.5 sh ~= 1.11 diff --git a/setup.py b/setup.py index 76db22a5..012f02fd 100644 --- a/setup.py +++ b/setup.py @@ -4,13 +4,13 @@ import setuptools -from gdm import __project__, __version__, CLI, DESCRIPTION +from gdm import __project__, __version__, CLI, PLUGIN, DESCRIPTION import os if os.path.exists('README.rst'): README = open('README.rst').read() else: - README = "" # a placeholder, readme is generated on release + README = "" # a placeholder, README is generated on release CHANGES = open('CHANGES.md').read() @@ -25,7 +25,10 @@ packages=setuptools.find_packages(), - entry_points={'console_scripts': [CLI + ' = gdm.cli:main']}, + entry_points={'console_scripts': [ + CLI + ' = gdm.cli:main', + 'git-' + PLUGIN + ' = gdm.plugin:main', + ]}, long_description=(README + '\n' + CHANGES), license='MIT',