diff --git a/README.md b/README.md index 79c70c3..7873724 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Dotbot makes installing your dotfiles as easy as `git clone $url && cd dotfiles - [Configuration](#configuration) - [Directives](#directives) ([Link](#link), [Create](#create), [Shell](#shell), [Clean](#clean), [Defaults](#defaults)) - [Plugins](#plugins) +- [Command-line Arguments](#command-line-arguments) - [Wiki][wiki] --- @@ -380,8 +381,24 @@ plugins: ``` -Wiki ----- +## Command-line Arguments + +Dotbot takes a number of command-line arguments; you can run Dotbot with +`--help`, e.g. by running `./install --help`, to see the full list of options. +Here, we highlight a couple that are particularly interesting. + +### `--only` + +You can call `./install --only [list of directives]`, such as `./install --only +link`, and Dotbot will only run those sections of the config file. + +### `--except` + +You can call `./install --except [list of directives]`, such as `./install +--except shell`, and Dotbot will run all the sections of the config file except +the ones listed. + +## Wiki Check out the [Dotbot wiki][wiki] for more information, tips and tricks, user-contributed plugins, and more. diff --git a/dotbot/cli.py b/dotbot/cli.py index 7275c1c..4a443a6 100644 --- a/dotbot/cli.py +++ b/dotbot/cli.py @@ -28,6 +28,10 @@ def add_options(parser): action='store_true', help='disable built-in plugins') parser.add_argument('--plugin-dir', action='append', dest='plugin_dirs', default=[], metavar='PLUGIN_DIR', help='load all plugins in PLUGIN_DIR') + parser.add_argument('--only', nargs='+', + help='only run specified directives', metavar='DIRECTIVE') + parser.add_argument('--except', nargs='+', dest='skip', + help='skip specified directives', metavar='DIRECTIVE') parser.add_argument('--no-color', dest='no_color', action='store_true', help='disable color output') parser.add_argument('--version', action='store_true', @@ -78,7 +82,7 @@ def main(): # default to directory of config file base_directory = os.path.dirname(os.path.abspath(options.config_file)) os.chdir(base_directory) - dispatcher = Dispatcher(base_directory) + dispatcher = Dispatcher(base_directory, only=options.only, skip=options.skip) success = dispatcher.dispatch(tasks) if success: log.info('\n==> All tasks executed successfully') diff --git a/dotbot/dispatcher.py b/dotbot/dispatcher.py index b33cb42..a222086 100644 --- a/dotbot/dispatcher.py +++ b/dotbot/dispatcher.py @@ -4,10 +4,12 @@ from .messenger import Messenger from .context import Context class Dispatcher(object): - def __init__(self, base_directory): + def __init__(self, base_directory, only=None, skip=None): self._log = Messenger() self._setup_context(base_directory) self._load_plugins() + self._only = only + self._skip = skip def _setup_context(self, base_directory): path = os.path.abspath( @@ -20,6 +22,10 @@ class Dispatcher(object): success = True for task in tasks: for action in task: + if self._only is not None and action not in self._only \ + or self._skip is not None and action in self._skip: + self._log.info('Skipping action %s' % action) + continue handled = False if action == 'defaults': self._context.set_defaults(task[action]) # replace, not update diff --git a/dotbot/plugins/clean.py b/dotbot/plugins/clean.py index 82d77a6..09b11d8 100644 --- a/dotbot/plugins/clean.py +++ b/dotbot/plugins/clean.py @@ -1,4 +1,6 @@ -import os, dotbot +import os +import dotbot + class Clean(dotbot.Plugin): ''' diff --git a/dotbot/plugins/create.py b/dotbot/plugins/create.py index dc119da..015645a 100644 --- a/dotbot/plugins/create.py +++ b/dotbot/plugins/create.py @@ -1,8 +1,5 @@ import os -import glob -import shutil import dotbot -import subprocess class Create(dotbot.Plugin): diff --git a/dotbot/plugins/link.py b/dotbot/plugins/link.py index 82a61ce..6f2b562 100644 --- a/dotbot/plugins/link.py +++ b/dotbot/plugins/link.py @@ -2,6 +2,7 @@ import os import glob import shutil import dotbot +import dotbot.util import subprocess @@ -105,14 +106,7 @@ class Link(dotbot.Plugin): return success def _test_success(self, command): - with open(os.devnull, 'w') as devnull: - ret = subprocess.call( - command, - shell=True, - stdout=devnull, - stderr=devnull, - executable=os.environ.get('SHELL'), - ) + ret = dotbot.util.shell_command(command, cwd=self._context.base_directory()) if ret != 0: self._log.debug('Test \'%s\' returned false' % command) return ret == 0 diff --git a/dotbot/plugins/shell.py b/dotbot/plugins/shell.py index 06a9a89..3092f20 100644 --- a/dotbot/plugins/shell.py +++ b/dotbot/plugins/shell.py @@ -1,4 +1,8 @@ -import os, subprocess, dotbot +import os +import subprocess +import dotbot +import dotbot.util + class Shell(dotbot.Plugin): ''' @@ -19,48 +23,40 @@ class Shell(dotbot.Plugin): def _process_commands(self, data): success = True defaults = self._context.defaults().get('shell', {}) - with open(os.devnull, 'w') as devnull: - for item in data: - stdin = stdout = stderr = devnull - quiet = False - if defaults.get('stdin', False) == True: - stdin = None - if defaults.get('stdout', False) == True: - stdout = None - if defaults.get('stderr', False) == True: - stderr = None - if defaults.get('quiet', False) == True: - quiet = True - if isinstance(item, dict): - cmd = item['command'] - msg = item.get('description', None) - if 'stdin' in item: - stdin = None if item['stdin'] == True else devnull - if 'stdout' in item: - stdout = None if item['stdout'] == True else devnull - if 'stderr' in item: - stderr = None if item['stderr'] == True else devnull - if 'quiet' in item: - quiet = True if item['quiet'] == True else False - elif isinstance(item, list): - cmd = item[0] - msg = item[1] if len(item) > 1 else None - else: - cmd = item - msg = None - if msg is None: - self._log.lowinfo(cmd) - elif quiet: - self._log.lowinfo('%s' % msg) - else: - self._log.lowinfo('%s [%s]' % (msg, cmd)) - executable = os.environ.get('SHELL') - ret = subprocess.call(cmd, shell=True, stdin=stdin, stdout=stdout, - stderr=stderr, cwd=self._context.base_directory(), - executable=executable) - if ret != 0: - success = False - self._log.warning('Command [%s] failed' % cmd) + for item in data: + stdin = defaults.get('stdin', False) + stdout = defaults.get('stdout', False) + stderr = defaults.get('stderr', False) + quiet = defaults.get('quiet', False) + if isinstance(item, dict): + cmd = item['command'] + msg = item.get('description', None) + stdin = item.get('stdin', stdin) + stdout = item.get('stdout', stdout) + stderr = item.get('stderr', stderr) + quiet = item.get('quiet', quiet) + elif isinstance(item, list): + cmd = item[0] + msg = item[1] if len(item) > 1 else None + else: + cmd = item + msg = None + if msg is None: + self._log.lowinfo(cmd) + elif quiet: + self._log.lowinfo('%s' % msg) + else: + self._log.lowinfo('%s [%s]' % (msg, cmd)) + ret = dotbot.util.shell_command( + cmd, + cwd=self._context.base_directory(), + enable_stdin=stdin, + enable_stdout=stdout, + enable_stderr=stderr + ) + if ret != 0: + success = False + self._log.warning('Command [%s] failed' % cmd) if success: self._log.info('All commands have been executed') else: diff --git a/dotbot/util/__init__.py b/dotbot/util/__init__.py index e69de29..0c5a8f5 100644 --- a/dotbot/util/__init__.py +++ b/dotbot/util/__init__.py @@ -0,0 +1 @@ +from .common import shell_command diff --git a/dotbot/util/common.py b/dotbot/util/common.py new file mode 100644 index 0000000..d1e2000 --- /dev/null +++ b/dotbot/util/common.py @@ -0,0 +1,34 @@ +import os +import subprocess +import platform + + +def shell_command(command, cwd=None, enable_stdin=False, enable_stdout=False, enable_stderr=False): + with open(os.devnull, 'w') as devnull_w, open(os.devnull, 'r') as devnull_r: + stdin = None if enable_stdin else devnull_r + stdout = None if enable_stdout else devnull_w + stderr = None if enable_stderr else devnull_w + executable = os.environ.get('SHELL') + if platform.system() == 'Windows': + # We avoid setting the executable kwarg on Windows because it does + # not have the desired effect when combined with shell=True. It + # will result in the correct program being run (e.g. bash), but it + # will be invoked with a '/c' argument instead of a '-c' argument, + # which it won't understand. + # + # See https://github.com/anishathalye/dotbot/issues/219 and + # https://bugs.python.org/issue40467. + # + # This means that complex commands that require Bash's parsing + # won't work; a workaround for this is to write the command as + # `bash -c "..."`. + executable = None + return subprocess.call( + command, + shell=True, + executable=executable, + stdin=stdin, + stdout=stdout, + stderr=stderr, + cwd=cwd + ) diff --git a/test/tests/except-multi.bash b/test/tests/except-multi.bash new file mode 100644 index 0000000..4193e66 --- /dev/null +++ b/test/tests/except-multi.bash @@ -0,0 +1,21 @@ +test_description='--except with multiple arguments' +. '../test-lib.bash' + +test_expect_success 'setup' ' +ln -s ${DOTFILES}/nonexistent ~/bad && touch ${DOTFILES}/y +' + +test_expect_success 'run' ' +run_dotbot --except clean shell < ~/x +- link: + ~/y: y +EOF +' + +test_expect_success 'test' ' +[ "$(readlink ~/bad | cut -d/ -f4-)" = "dotfiles/nonexistent" ] && + ! test -f ~/x && test -f ~/y +' diff --git a/test/tests/except.bash b/test/tests/except.bash new file mode 100644 index 0000000..356eaef --- /dev/null +++ b/test/tests/except.bash @@ -0,0 +1,31 @@ +test_description='--except' +. '../test-lib.bash' + +test_expect_success 'setup' ' +echo "apple" > ${DOTFILES}/x +' + +test_expect_success 'run' ' +run_dotbot --except link < ~/y +- link: + ~/x: x +EOF +' + +test_expect_success 'test' ' +grep "pear" ~/y && ! test -f ~/x +' + +test_expect_success 'run 2' ' +run_dotbot --except shell < ~/z +- link: + ~/x: x +' + +test_expect_success 'test' ' +grep "apple" ~/x && ! test -f ~/z +' diff --git a/test/tests/only-multi.bash b/test/tests/only-multi.bash new file mode 100644 index 0000000..e8d8362 --- /dev/null +++ b/test/tests/only-multi.bash @@ -0,0 +1,20 @@ +test_description='--only with multiple arguments' +. '../test-lib.bash' + +test_expect_success 'setup' ' +ln -s ${DOTFILES}/nonexistent ~/bad && touch ${DOTFILES}/y +' + +test_expect_success 'run' ' +run_dotbot --only clean shell < ~/x +- link: + ~/y: y +EOF +' + +test_expect_success 'test' ' +! test -f ~/bad && grep "x" ~/x && ! test -f ~/y +' diff --git a/test/tests/only.bash b/test/tests/only.bash new file mode 100644 index 0000000..5222881 --- /dev/null +++ b/test/tests/only.bash @@ -0,0 +1,31 @@ +test_description='--only' +. '../test-lib.bash' + +test_expect_success 'setup' ' +echo "apple" > ${DOTFILES}/x +' + +test_expect_success 'run' ' +run_dotbot --only shell < ~/y +- link: + ~/x: x +EOF +' + +test_expect_success 'test' ' +grep "pear" ~/y && ! test -f ~/x +' + +test_expect_success 'run 2' ' +run_dotbot --only link < ~/z +- link: + ~/x: x +' + +test_expect_success 'test' ' +grep "apple" ~/x && ! test -f ~/z +'