diff --git a/README.md b/README.md index e2e27b1..bec819e 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,9 @@ cp dotbot/tools/hg-subrepo/install . touch install.conf.yaml ``` +If you are using PowerShell instead of a POSIX shell, you can use the provided +`install.ps1` script instead of `install`. + To get started, you just need to fill in the `install.conf.yaml` and Dotbot will take care of the rest. To help you get started we have [an example](#full-example) config file as well as [configuration @@ -177,10 +180,17 @@ mapped to extended configuration dictionaries. | `relink` | Removes the old target if it's a symlink (default: false) | | `force` | Force removes the old target, file or folder, and forces a new link (default: false) | | `relative` | Use a relative path to the source when creating the symlink (default: false, absolute links) | -| `canonicalize-path` | Resolve any symbolic links encountered in the source to symlink to the canonical path (default: true, real paths) | +| `canonicalize` | Resolve any symbolic links encountered in the source to symlink to the canonical path (default: true, real paths) | | `glob` | Treat a `*` character as a wildcard, and perform link operations on all of those matches (default: false) | | `if` | Execute this in your `$SHELL` and only link if it is successful. | | `ignore-missing` | Do not fail if the source is missing and create the link anyway (default: false) | +| `exclude` | Array of paths to remove from glob matches. Uses same syntax as `path`. Ignored if `glob` is `false`. (default: empty, keep all matches) | + +Dotbot uses [glob.glob](https://docs.python.org/3/library/glob.html#glob.glob) +to resolve glob paths. However, due to its design, using a glob path such as +`config/*` for example, will not match items that being with `.`. To +specifically capture items that being with `.`, you will need to use a path +like this: `config/.*`. #### Example @@ -221,6 +231,12 @@ Explicit sources: glob: true path: config/* relink: true + exclude: [ config/Code ] + ~/.config/Code/User/: + create: true + glob: true + path: config/Code/User/* + relink: true ``` Implicit sources: @@ -237,6 +253,12 @@ Implicit sources: glob: true path: config/* relink: true + exclude: [ config/Code ] + ~/.config/Code/User/: + create: true + glob: true + path: config/Code/User/* + relink: true ``` ### Create @@ -247,15 +269,30 @@ apps, plugins, shell commands, etc. #### Format -Create commands are specified as an array of directories to be created. +Create commands are specified as an array of directories to be created. If you +want to use the optional extended configuration, create commands are specified +as dictionaries. For convenience, it's permissible to leave the options blank +(null) in the dictionary syntax. + +| Parameter | Explanation | +| --- | --- | +| `mode` | The file mode to use for creating the leaf directory (default: 0777) | + +The `mode` parameter is treated in the same way as in Python's +[os.mkdir](https://docs.python.org/3/library/os.html#mkdir-modebits). Its +behavior is platform-dependent. On Unix systems, the current umask value is +first masked out. #### Example ```yaml - create: - - ~/projects - ~/downloads - ~/.vim/undo-history +- create: + ~/.ssh: + mode: 0700 + ~/projects: ``` ### Shell diff --git a/dotbot/cli.py b/dotbot/cli.py index 2b60b11..57e0f31 100644 --- a/dotbot/cli.py +++ b/dotbot/cli.py @@ -1,7 +1,7 @@ import os, glob import sys -from argparse import ArgumentParser +from argparse import ArgumentParser, RawTextHelpFormatter from .config import ConfigReader, ReadingError from .dispatcher import Dispatcher, DispatchError from .messenger import Messenger @@ -11,31 +11,36 @@ from .util import module import dotbot import yaml - def add_options(parser): - parser.add_argument('-Q', '--super-quiet', action='store_true', help='suppress almost all output') - parser.add_argument('-q', '--quiet', action='store_true', help='suppress most output') - parser.add_argument('-v', '--verbose', action='store_true', help='enable verbose output') - parser.add_argument('-d', '--base-directory', help='execute commands from within BASEDIR', metavar='BASEDIR') - parser.add_argument('-c', '--config-file', help='run commands given in CONFIGFILE', metavar='CONFIGFILE') - parser.add_argument( - '-p', '--plugin', action='append', dest='plugins', default=[], help='load PLUGIN as a plugin', metavar='PLUGIN' - ) - parser.add_argument('--disable-built-in-plugins', 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('--force-color', dest='force_color', action='store_true', help='force color output') - parser.add_argument('--no-color', dest='no_color', action='store_true', help='disable color output') - parser.add_argument('--version', action='store_true', help="show program's version number and exit") - + parser.add_argument('-Q', '--super-quiet', action='store_true', + help='suppress almost all output') + parser.add_argument('-q', '--quiet', action='store_true', + help='suppress most output') + parser.add_argument('-v', '--verbose', action='count', default=0, + help='enable verbose output\n' + '-v: typical verbose\n' + '-vv: also, set shell commands stderr/stdout to true') + parser.add_argument('-d', '--base-directory', + help='execute commands from within BASEDIR', + metavar='BASEDIR') + parser.add_argument('-c', '--config-file', + help='run commands given in CONFIGFILE', metavar='CONFIGFILE') + parser.add_argument('-p', '--plugin', action='append', dest='plugins', default=[], + help='load PLUGIN as a plugin', metavar='PLUGIN') + parser.add_argument('--disable-built-in-plugins', + 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('--force-color', dest='force_color', action='store_true', + help='force color output') + parser.add_argument('--no-color', dest='no_color', action='store_true', + help='disable color output') + parser.add_argument('--version', action='store_true', + help='show program\'s version number and exit') def read_config(config_file): reader = ConfigReader(config_file) @@ -45,20 +50,20 @@ def read_config(config_file): def main(additional_args=None): log = Messenger() try: - parser = ArgumentParser() + parser = ArgumentParser(formatter_class=RawTextHelpFormatter) add_options(parser) options = parser.parse_args() if additional_args is not None: print("got explicit arguments") options = parser.parse_args(additional_args) if options.version: - print("Dotbot version %s (yaml: %s)" % (dotbot.__version__, yaml.__version__)) + print('Dotbot version %s (yaml: %s)' % (dotbot.__version__, yaml.__version__)) exit(0) if options.super_quiet: log.set_level(Level.WARNING) if options.quiet: log.set_level(Level.INFO) - if options.verbose: + if options.verbose > 0: log.set_level(Level.DEBUG) if options.force_color and options.no_color: @@ -76,38 +81,38 @@ def main(additional_args=None): from .plugins import Clean, Create, Link, Shell plugin_paths = [] for directory in plugin_directories: - for plugin_path in glob.glob(os.path.join(directory, "*.py")): - plugin_paths.append(plugin_path) + for plugin_path in glob.glob(os.path.join(directory, '*.py')): + plugin_paths.append(plugin_path) for plugin_path in options.plugins: plugin_paths.append(plugin_path) for plugin_path in plugin_paths: abspath = os.path.abspath(plugin_path) module.load(abspath) if not options.config_file: - log.error("No configuration file specified") + log.error('No configuration file specified') exit(1) # read tasks from config file tasks = read_config(options.config_file) if tasks is None: - log.warning("Configuration file is empty, no work to do") + log.warning('Configuration file is empty, no work to do') tasks = [] if not isinstance(tasks, list): - raise ReadingError("Configuration file must be a list of tasks") + raise ReadingError('Configuration file must be a list of tasks') if options.base_directory: base_directory = os.path.abspath(options.base_directory) else: # 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, only=options.only, skip=options.skip) + dispatcher = Dispatcher(base_directory, only=options.only, skip=options.skip, options=options) success = dispatcher.dispatch(tasks) if success: - log.info("\n==> All tasks executed successfully") + log.info('\n==> All tasks executed successfully') else: - raise DispatchError("\n==> Some tasks were not executed successfully") + raise DispatchError('\n==> Some tasks were not executed successfully') except (ReadingError, DispatchError) as e: - log.error("%s" % e) + log.error('%s' % e) exit(1) except KeyboardInterrupt: - log.error("\n==> Operation aborted") + log.error('\n==> Operation aborted') exit(1) diff --git a/dotbot/context.py b/dotbot/context.py index 8942c5f..da10ba4 100644 --- a/dotbot/context.py +++ b/dotbot/context.py @@ -1,5 +1,6 @@ import copy import os +from argparse import Namespace class Context(object): @@ -7,9 +8,10 @@ class Context(object): Contextual data and information for plugins. """ - def __init__(self, base_directory): + def __init__(self, base_directory, options=Namespace()): self._base_directory = base_directory self._defaults = {} + self._options = options pass def set_base_directory(self, base_directory): @@ -26,3 +28,6 @@ class Context(object): def defaults(self): return copy.deepcopy(self._defaults) + + def options(self): + return copy.deepcopy(self._options) diff --git a/dotbot/dispatcher.py b/dotbot/dispatcher.py index 113442d..6696599 100644 --- a/dotbot/dispatcher.py +++ b/dotbot/dispatcher.py @@ -1,4 +1,5 @@ import os +from argparse import Namespace from .plugin import Plugin from .messenger import Messenger from .context import Context @@ -6,18 +7,18 @@ import traceback class Dispatcher(object): - def __init__(self, base_directory, only=None, skip=None): + def __init__(self, base_directory, only=None, skip=None, options=Namespace()): self._log = Messenger() - self._setup_context(base_directory) + self._setup_context(base_directory, options) self._load_plugins() self._only = only self._skip = skip - def _setup_context(self, base_directory): + def _setup_context(self, base_directory, options): path = os.path.abspath(os.path.expanduser(base_directory)) if not os.path.exists(path): - raise DispatchError("Nonexistent base directory") - self._context = Context(path) + raise DispatchError('Nonexistent base directory') + self._context = Context(path, options) def dispatch(self, tasks): success = True @@ -32,15 +33,12 @@ class Dispatcher(object): self._log.info("Skipping action %s" % action) continue handled = False - # print("\tcurrent action", action) - if action == "defaults": + if action == 'defaults': self._context.set_defaults(task[action]) # replace, not update handled = True # keep going, let other plugins handle this if they want for plugin in self._plugins: - if plugin.can_handle(action): - # print("Action:", action) try: success &= plugin.handle(action, task[action]) handled = True @@ -51,7 +49,7 @@ class Dispatcher(object): self._log.debug(err) if not handled: success = False - self._log.error('Action "%s" not handled' % action) + self._log.error('Action %s not handled' % action) return success def _load_plugins(self): diff --git a/dotbot/plugins/create.py b/dotbot/plugins/create.py index 55e2ab4..9250443 100644 --- a/dotbot/plugins/create.py +++ b/dotbot/plugins/create.py @@ -3,48 +3,54 @@ import dotbot class Create(dotbot.Plugin): - """ + ''' Create empty paths. - """ + ''' - _directive = "create" + _directive = 'create' def can_handle(self, directive): return directive == self._directive def handle(self, directive, data): if directive != self._directive: - raise ValueError("Create cannot handle directive %s" % directive) + raise ValueError('Create cannot handle directive %s' % directive) return self._process_paths(data) def _process_paths(self, paths): success = True - for path in paths: - path = os.path.normpath(os.path.expandvars(os.path.expanduser(path))) - success &= self._create(path) + defaults = self._context.defaults().get('create', {}) + for key in paths: + path = os.path.normpath(os.path.expandvars(os.path.expanduser(key))) + mode = defaults.get('mode', 0o777) # same as the default for os.makedirs + if isinstance(paths, dict): + options = paths[key] + if options: + mode = options.get('mode', mode) + success &= self._create(path, mode) if success: - self._log.info("All paths have been set up") + self._log.info('All paths have been set up') else: - self._log.error("Some paths were not successfully set up") + self._log.error('Some paths were not successfully set up') return success def _exists(self, path): - """ + ''' Returns true if the path exists. - """ + ''' path = os.path.expanduser(path) return os.path.exists(path) - def _create(self, path): + def _create(self, path, mode): success = True if not self._exists(path): - self._log.debug("Trying to create path %s" % path) + self._log.debug('Trying to create path %s with mode %o' % (path, mode)) try: - self._log.lowinfo("Creating path %s" % path) - os.makedirs(path) + self._log.lowinfo('Creating path %s' % path) + os.makedirs(path, mode) except OSError: - self._log.warning("Failed to create path %s" % path) + self._log.warning('Failed to create path %s' % path) success = False else: - self._log.lowinfo("Path exists %s" % path) + self._log.lowinfo('Path exists %s' % path) return success diff --git a/dotbot/plugins/link.py b/dotbot/plugins/link.py index 2f59e8e..07aa5be 100644 --- a/dotbot/plugins/link.py +++ b/dotbot/plugins/link.py @@ -7,39 +7,41 @@ import subprocess class Link(dotbot.Plugin): - """ + ''' Symbolically links dotfiles. - """ + ''' - _directive = "link" + _directive = 'link' def can_handle(self, directive): return directive == self._directive def handle(self, directive, data): if directive != self._directive: - raise ValueError("Link cannot handle directive %s" % directive) + raise ValueError('Link cannot handle directive %s' % directive) return self._process_links(data) def _get_default_flags(self): """Get flags for process links from default file.""" defaults = self._context.defaults().get("link", {}) relative = defaults.get("relative", False) - canonical_path = defaults.get("canonicalize-path", True) + canonical_path = defaults.get("canonicalize", defaults.get("canonicalize-path", True)) force = defaults.get("force", False) relink = defaults.get("relink", False) create = defaults.get("create", False) use_glob = defaults.get("glob", False) test = defaults.get("if", None) ignore_missing = defaults.get("ignore-missing", False) - return relative, canonical_path, force, relink, create, use_glob, test, ignore_missing + exclude_paths = defaults.get('exclude', []) + return relative, canonical_path, force, relink, create, use_glob, test, ignore_missing, exclude_paths def _process_links(self, links_dict): # print("symlinking\n\t", links) success = True (relative_default, canonical_path_default, force_flag_default, relink_flag_default, - create_dir_flag_default, use_glob_default, shell_command_default, ignore_missing_default) = self._get_default_flags() + create_dir_flag_default, use_glob_default, shell_command_default, + ignore_missing_default, exclude_paths_default) = self._get_default_flags() for destination, source_dict in links_dict.items(): destination = os.path.expandvars(destination) @@ -49,36 +51,38 @@ class Link(dotbot.Plugin): # extended config shell_command = source_dict.get("if", shell_command_default) relative = source_dict.get("relative", relative_default) - canonical_path = source_dict.get("canonicalize-path", canonical_path_default) + # support old "canonicalize-path" key for compatibility + canonical_path = source_dict.get("canonicalize", source_dict.get( + "canonicalize-path", canonical_path_default)) force_flag = source_dict.get("force", force_flag_default) relink_flag = source_dict.get("relink", relink_flag_default) create_dir_flag = source_dict.get("create", create_dir_flag_default) use_glob = source_dict.get("glob", use_glob_default) ignore_missing = source_dict.get("ignore-missing", ignore_missing_default) + exclude_paths = source_dict.get("exclude", exclude_paths_default) else: path = self._default_source(destination, source_dict) (shell_command, relative, canonical_path, force_flag, relink_flag, - create_dir_flag, use_glob, ignore_missing) = (shell_command_default, relative_default, canonical_path_default, force_flag_default, relink_flag_default, - create_dir_flag_default, use_glob_default, ignore_missing_default) + create_dir_flag, use_glob, ignore_missing, exclude_paths) = (shell_command_default, + relative_default, canonical_path_default, force_flag_default, relink_flag_default, + create_dir_flag_default, use_glob_default, ignore_missing_default, exclude_paths_default) if shell_command is not None and not self._test_success(shell_command): self._log.lowinfo("Skipping %s" % destination) continue path = os.path.expandvars(os.path.expanduser(path)) if use_glob: - self._log.debug("Globbing with path: " + str(path)) - glob_results = glob.glob(path) + glob_results = self._create_glob_results(path, exclude_paths) if len(glob_results) == 0: self._log.warning("Globbing couldn't find anything matching " + str(path)) success = False continue - glob_star_loc = path.find("*") - if glob_star_loc == -1 and destination[-1] == "/": + glob_star_loc = path.find('*') + if glob_star_loc == -1 and destination[-1] == '/': self._log.error("Ambiguous action requested.") - self._log.error( - "No wildcard in glob, directory use undefined: " + destination + " -> " + str(glob_results) - ) + self._log.error("No wildcard in glob, directory use undefined: " + + destination + " -> " + str(glob_results)) self._log.warning("Did you want to link the directory or into it?") success = False continue @@ -92,8 +96,10 @@ class Link(dotbot.Plugin): else: self._log.lowinfo("Globs from '" + path + "': " + str(glob_results)) glob_base = path[:glob_star_loc] + if glob_base.endswith('/.') or glob_base == '.': + glob_base = path[:glob_star_loc - 1] for glob_full_item in glob_results: - glob_item = glob_full_item[len(glob_base) :] + glob_item = glob_full_item[len(glob_base):] glob_link_destination = os.path.join(destination, glob_item) if create_dir_flag: success &= self._create_dir(glob_link_destination) @@ -115,15 +121,16 @@ class Link(dotbot.Plugin): # want to remove the original (this is tested by # link-force-leaves-when-nonexistent.bash) success = False - self._log.warning("Nonexistent source %s -> %s" % (destination, path)) + self._log.warning('Nonexistent source %s -> %s' % + (destination, path)) continue if force_flag or relink_flag: success &= self._delete(path, destination, relative, canonical_path, force_flag) success &= self._link(path, destination, relative, canonical_path, ignore_missing) if success: - self._log.info("All links have been set up") + self._log.info('All links have been set up') else: - self._log.error("Some links were not successfully set up") + self._log.error('Some links were not successfully set up') return success def _test_success(self, command): @@ -135,25 +142,36 @@ class Link(dotbot.Plugin): def _default_source(self, destination, source): if source is None: basename = os.path.basename(destination) - if basename.startswith("."): + if basename.startswith('.'): return basename[1:] else: return basename else: return source + def _create_glob_results(self, path, exclude_paths): + self._log.debug("Globbing with path: " + str(path)) + base_include = glob.glob(path) + to_exclude = [] + for expath in exclude_paths: + self._log.debug("Excluding globs with path: " + str(expath)) + to_exclude.extend(glob.glob(expath)) + self._log.debug("Excluded globs from '" + path + "': " + str(to_exclude)) + ret = set(base_include) - set(to_exclude) + return list(ret) + def _is_link(self, path): - """ + ''' Returns true if the path is a symbolic link. - """ + ''' return os.path.islink(os.path.expanduser(path)) def _get_link_destination(self, path): - """ + ''' Returns the destination of the symbolic link. Truncates the \\?\ start to a path if it is present. This is an identifier which allows >255 character file name links to work. Since this function is for the point of comparison, it is okay to truncate - """ + ''' # path = os.path.normpath(path) path = os.path.expanduser(path) try: @@ -173,9 +191,9 @@ class Link(dotbot.Plugin): return read_link def _exists(self, path): - """ + ''' Returns true if the path exists. Returns false if contains dangling symbolic links. - """ + ''' path = os.path.expanduser(path) return os.path.exists(path) @@ -188,10 +206,10 @@ class Link(dotbot.Plugin): try: os.makedirs(parent) except OSError: - self._log.warning("Failed to create directory %s" % parent) + self._log.warning('Failed to create directory %s' % parent) success = False else: - self._log.lowinfo("Creating directory %s" % parent) + self._log.lowinfo('Creating directory %s' % parent) return success def _delete(self, source, path, relative, canonical_path, force): @@ -216,29 +234,29 @@ class Link(dotbot.Plugin): os.remove(fullpath) removed = True except OSError: - self._log.warning("Failed to remove %s" % path) + self._log.warning('Failed to remove %s' % path) success = False else: if removed: - self._log.lowinfo("Removing %s" % path) + self._log.lowinfo('Removing %s' % path) return success def _relative_path(self, source, destination): - """ + ''' Returns the relative path to get to the source file from the destination file. - """ + ''' destination_dir = os.path.dirname(destination) return os.path.relpath(source, destination_dir) def _link(self, dotfile_source, target_path_to_link_at, relative_path, canonical_path, ignore_missing): - """ + ''' Links link_name to source. :param target_path_to_link_at is the file path where we are putting a symlink back to dotfile_source Returns true if successfully linked files. - """ + ''' success_flag = False destination = os.path.normpath(os.path.expanduser(target_path_to_link_at)) base_directory = self._context.base_directory(canonical_path=canonical_path) diff --git a/dotbot/plugins/shell.py b/dotbot/plugins/shell.py index 4a1d8a9..bae8b99 100644 --- a/dotbot/plugins/shell.py +++ b/dotbot/plugins/shell.py @@ -5,35 +5,38 @@ import dotbot.util class Shell(dotbot.Plugin): - """ + ''' Run arbitrary shell commands. - """ + ''' - _directive = "shell" + _directive = 'shell' + _has_shown_override_message = False def can_handle(self, directive): return directive == self._directive def handle(self, directive, data): if directive != self._directive: - raise ValueError("Shell cannot handle directive %s" % directive) + raise ValueError('Shell cannot handle directive %s' % + directive) return self._process_commands(data) def _process_commands(self, data): success = True - defaults = self._context.defaults().get("shell", {}) + defaults = self._context.defaults().get('shell', {}) + options = self._get_option_overrides() for item in data: - stdin = defaults.get("stdin", False) - stdout = defaults.get("stdout", False) - stderr = defaults.get("stderr", False) - quiet = defaults.get("quiet", False) + 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) + 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 @@ -43,17 +46,34 @@ class Shell(dotbot.Plugin): if msg is None: self._log.lowinfo(cmd) elif quiet: - self._log.lowinfo("%s" % msg) + self._log.lowinfo('%s' % msg) else: - self._log.lowinfo("%s [%s]" % (msg, cmd)) + self._log.lowinfo('%s [%s]' % (msg, cmd)) + stdout = options.get('stdout', stdout) + stderr = options.get('stderr', stderr) ret = dotbot.util.shell_command( - cmd, cwd=self._context.base_directory(), enable_stdin=stdin, enable_stdout=stdout, enable_stderr=stderr + 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) + self._log.warning('Command [%s] failed' % cmd) if success: - self._log.info("All commands have been executed") + self._log.info('All commands have been executed') else: - self._log.error("Some commands were not successfully executed") + self._log.error('Some commands were not successfully executed') return success + + def _get_option_overrides(self): + ret = {} + options = self._context.options() + if options.verbose > 1: + ret['stderr'] = True + ret['stdout'] = True + if not self._has_shown_override_message: + self._log.debug("Shell: Found cli option to force show stderr and stdout.") + self._has_shown_override_message = True + return ret diff --git a/test/README.md b/test/README.md index 9ec79f5..a4d8208 100644 --- a/test/README.md +++ b/test/README.md @@ -51,8 +51,8 @@ edits on your host machine). You can run the test suite by `cd /dotbot/test` and then running `./test`. Selected tests can be run by passing paths to the tests as arguments, e.g. `./test tests/create.bash tests/defaults.bash`. -To debug tests, you can prepend the line `DEBUG=true` as the first line to any -individual test (a `.bash` file inside `test/tests`). This will enable printing +To debug tests, you can run the test driver with the `--debug` (or `-d` short +form) flag, e.g. `./test --debug tests/link-if.bash`. This will enable printing stdout/stderr. When finished with testing, it is good to shut down the virtual machine by diff --git a/test/driver-lib.bash b/test/driver-lib.bash index ab59240..b7b314e 100644 --- a/test/driver-lib.bash +++ b/test/driver-lib.bash @@ -60,7 +60,7 @@ run_test() { tests_run=$((tests_run + 1)) printf '[%d/%d] (%s)\n' "${tests_run}" "${tests_total}" "${1}" cleanup - if (cd "${BASEDIR}/test/tests" && HOME=~/fakehome DOTBOT_TEST=true bash "${1}"); then + if (cd "${BASEDIR}/test/tests" && HOME=~/fakehome DEBUG=${2} DOTBOT_TEST=true bash "${1}"); then pass else fail diff --git a/test/test b/test/test index f944512..c52932b 100755 --- a/test/test +++ b/test/test @@ -10,6 +10,23 @@ start="$(date +%s)" check_env +# parse flags; must come before positional arguments +POSITIONAL=() +DEBUG=false +while [[ $# -gt 0 ]]; do + case $1 in + -d|--debug) + DEBUG=true + shift + ;; + *) + POSITIONAL+=("$1") + shift + ;; + esac +done +set -- "${POSITIONAL[@]}" # restore positional arguments + declare -a tests=() if [ $# -eq 0 ]; then @@ -20,10 +37,10 @@ else tests=("$@") fi -initialize "${#tests[@]}" "${VERSION}" +initialize "${#tests[@]}" for file in "${tests[@]}"; do - run_test "$(basename "${file}")" "${VERSION}" + run_test "$(basename "${file}")" "${DEBUG}" done if report; then diff --git a/test/test-lib.bash b/test/test-lib.bash index 5472e46..1fa72cb 100644 --- a/test/test-lib.bash +++ b/test/test-lib.bash @@ -1,4 +1,3 @@ -DEBUG=${DEBUG:-false} DOTBOT_EXEC="${BASEDIR}/bin/dotbot" DOTFILES="${HOME}/dotfiles" INSTALL_CONF='install.conf.yaml' diff --git a/test/tests/create-mode.bash b/test/tests/create-mode.bash new file mode 100644 index 0000000..e2dd649 --- /dev/null +++ b/test/tests/create-mode.bash @@ -0,0 +1,26 @@ +test_description='create mode' +. '../test-lib.bash' + +test_expect_success 'run' ' +run_dotbot -v < ~/z - link: ~/x: x +EOF ' test_expect_success 'test' ' diff --git a/test/tests/link-glob-exclude.bash b/test/tests/link-glob-exclude.bash new file mode 100644 index 0000000..bb6fb76 --- /dev/null +++ b/test/tests/link-glob-exclude.bash @@ -0,0 +1,123 @@ +test_description='link glob exclude' +. '../test-lib.bash' + +test_expect_success 'setup 1' ' +mkdir -p ${DOTFILES}/config/{foo,bar,baz} && +echo "apple" > ${DOTFILES}/config/foo/a && +echo "banana" > ${DOTFILES}/config/bar/b && +echo "cherry" > ${DOTFILES}/config/bar/c && +echo "donut" > ${DOTFILES}/config/baz/d +' + +test_expect_success 'run 1' ' +run_dotbot -v < ${DOTFILES}/config/baz/buzz/e +' + +test_expect_success 'run 2' ' +run_dotbot -v < ${DOTFILES}/config/baz/bizz/g +' + +test_expect_success 'run 3' ' +run_dotbot -v < ${DOTFILES}/config/fiz/f +' + +test_expect_success 'run 4' ' +run_dotbot -v < ${DOTFILES}/bin/.a && +echo "dot_banana" > ${DOTFILES}/bin/.b && +echo "dot_cherry" > ${DOTFILES}/bin/.c +' + +test_expect_success 'run 3' ' +run_dotbot -v < ${DOTFILES}/.a && +echo "dot_banana" > ${DOTFILES}/.b && +echo "dot_cherry" > ${DOTFILES}/.c +' + +test_expect_success 'run 4' ' +run_dotbot -v < ${DOTFILES}/f && +echo "grape" > ${DOTFILES}/g && ln -s dotfiles dotfiles-symlink ' @@ -21,3 +22,19 @@ ${DOTBOT_EXEC} -c ./dotfiles-symlink/${INSTALL_CONF} test_expect_success 'test' ' [ "$(readlink ~/.f | cut -d/ -f5-)" = "dotfiles-symlink/f" ] ' + +test_expect_success 'run 2' ' +cat > "${DOTFILES}/${INSTALL_CONF}" < ~/z - link: ~/d/x: x +EOF ' test_expect_success 'test' ' diff --git a/test/tests/only.bash b/test/tests/only.bash index 5222881..1f74441 100644 --- a/test/tests/only.bash +++ b/test/tests/only.bash @@ -24,6 +24,7 @@ run_dotbot --only link < ~/z - link: ~/x: x +EOF ' test_expect_success 'test' ' diff --git a/test/tests/plugin.bash b/test/tests/plugin.bash index bdf0c7f..dc2613a 100644 --- a/test/tests/plugin.bash +++ b/test/tests/plugin.bash @@ -1,7 +1,7 @@ test_description='plugin loading works' . '../test-lib.bash' -test_expect_success 'setup' ' +test_expect_success 'setup 1' ' cat > ${DOTFILES}/test.py < ${DOTFILES}/test.py <&2 +EOF +' + +test_expect_success 'run 5' ' +(run_dotbot -vv 2>&1 | (grep "^apple")) <&2 +EOF +' + +test_expect_success 'run 6' ' +(run_dotbot -vv 2>&1 | (grep "^apple")) <&2 + stdout: false +EOF +' + +test_expect_success 'run 7' ' +(run_dotbot -vv 2>&1 | (grep "^apple")) <&2 +EOF +' + +# Make sure that we must use verbose level 2 +# This preserves backwards compatability +test_expect_failure 'run 8' ' +(run_dotbot -v | (grep "^apple")) <&2 +EOF +' diff --git a/tools/git-submodule/install.ps1 b/tools/git-submodule/install.ps1 new file mode 100644 index 0000000..a5940cf --- /dev/null +++ b/tools/git-submodule/install.ps1 @@ -0,0 +1,22 @@ +$ErrorActionPreference = "Stop" + +$CONFIG = "install.conf.yaml" +$DOTBOT_DIR = "dotbot" + +$DOTBOT_BIN = "bin/dotbot" +$BASEDIR = $PSScriptRoot + +Set-Location $BASEDIR +git -C $DOTBOT_DIR submodule sync --quiet --recursive +git submodule update --init --recursive $DOTBOT_DIR + +foreach ($PYTHON in ('python', 'python3', 'python2')) { + # Python redirects to Microsoft Store in Windows 10 when not installed + if (& { $ErrorActionPreference = "SilentlyContinue" + ![string]::IsNullOrEmpty((&$PYTHON -V)) + $ErrorActionPreference = "Stop" }) { + &$PYTHON $(Join-Path $BASEDIR -ChildPath $DOTBOT_DIR | Join-Path -ChildPath $DOTBOT_BIN) -d $BASEDIR -c $CONFIG $Args + return + } +} +Write-Error "Error: Cannot find Python." diff --git a/tools/hg-subrepo/install.ps1 b/tools/hg-subrepo/install.ps1 new file mode 100644 index 0000000..39078bf --- /dev/null +++ b/tools/hg-subrepo/install.ps1 @@ -0,0 +1,21 @@ +$ErrorActionPreference = "Stop" + +$CONFIG = "install.conf.yaml" +$DOTBOT_DIR = "dotbot" + +$DOTBOT_BIN = "bin/dotbot" +$BASEDIR = $PSScriptRoot + +Set-Location $BASEDIR + +Set-Location $DOTBOT_DIR && git submodule update --init --recursive +foreach ($PYTHON in ('python', 'python3', 'python2')) { + # Python redirects to Microsoft Store in Windows 10 when not installed + if (& { $ErrorActionPreference = "SilentlyContinue" + ![string]::IsNullOrEmpty((&$PYTHON -V)) + $ErrorActionPreference = "Stop" }) { + &$PYTHON $(Join-Path $BASEDIR -ChildPath $DOTBOT_DIR | Join-Path -ChildPath $DOTBOT_BIN) -d $BASEDIR -c $CONFIG $Args + return + } +} +Write-Error "Error: Cannot find Python."