diff --git a/docs/config/config-config.md b/docs/config/config-config.md index 80747bd46..761f1744d 100644 --- a/docs/config/config-config.md +++ b/docs/config/config-config.md @@ -4,7 +4,7 @@ The **config** entry (mandatory) contains global settings. Entry | Description | Default -------- | ------------- | ------------ -`backup` | Create a backup of the dotfile in case it differs from the one that will be installed by dotdrop | true +`backup` | Create a backup of the existing destination; see [backup entry](config-config.md#backup-entry)) | true `banner` | Display the banner | true `check_version` | Check if a new version of dotdrop is available on github | false `chmod_on_import` | Always add a chmod entry on newly imported dotfiles (see `--preserve-mode`) | false @@ -212,4 +212,16 @@ profiles: hostname: dotfiles: - f_vimrc -``` \ No newline at end of file +``` + +## backup entry + +When set to `true`, existing files that would be replaced +by a dotdrop `install`, are backed up with the +extension `.dotdropbak` if their content differ. + +Note: +* directories will **not** be backed up, only files +* when using a different `link` value than `nolink` with directories, + the files under the directory will **not** be backed up + (See [Symlinking dotfiles](config-file.md#symlinking-dotfiles)), \ No newline at end of file diff --git a/docs/usage.md b/docs/usage.md index 75c6d4257..50de1f512 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -234,6 +234,21 @@ dotdrop. It will: For more options, see the usage with `dotdrop --help`. +## Uninstall dotfiles + +The `uninstall` command removes dotfiles installed by dotdrop +```bash +$ dotdrop uninstall +``` + +It will remove the installed dotfiles related to the provided key +(or all dotfiles if not provided) of the selected profile. + +If a backup exists ([backup entry](config/config-config.md#backup-entry)), +the file will be restored. + +For more options, see the usage with `dotdrop --help`. + ## Concurrency The command line switch `-w`/`--workers`, if set to a value greater than one, enables the use diff --git a/dotdrop/dotdrop.py b/dotdrop/dotdrop.py index 932ab7543..e9ed43068 100644 --- a/dotdrop/dotdrop.py +++ b/dotdrop/dotdrop.py @@ -16,6 +16,7 @@ from dotdrop.logger import Logger from dotdrop.templategen import Templategen from dotdrop.installer import Installer +from dotdrop.uninstaller import Uninstaller from dotdrop.updater import Updater from dotdrop.comparator import Comparator from dotdrop.importer import Importer @@ -618,6 +619,47 @@ def cmd_detail(opts): LOG.log('') +def cmd_uninstall(opts): + """uninstall""" + dotfiles = opts.dotfiles + keys = opts.uninstall_key + + if keys: + # update only specific keys for this profile + dotfiles = [] + for key in uniq_list(keys): + dotfile = opts.conf.get_dotfile(key) + if dotfile: + dotfiles.append(dotfile) + + if not dotfiles: + msg = f'no dotfile to uninstall for this profile (\"{opts.profile}\")' + LOG.warn(msg) + return False + + if opts.debug: + lfs = [k.key for k in dotfiles] + LOG.dbg(f'dotfiles registered for uninstall: {lfs}') + + uninst = Uninstaller(base=opts.dotpath, + workdir=opts.workdir, + dry=opts.dry, + safe=opts.safe, + debug=opts.debug, + backup_suffix=opts.install_backup_suffix) + uninstalled = 0 + for dotf in dotfiles: + res, msg = uninst.uninstall(dotf.src, + dotf.dst, + dotf.link) + if not res: + LOG.err(msg) + continue + uninstalled += 1 + LOG.log(f'\n{uninstalled} dotfile(s) uninstalled.') + return True + + def cmd_remove(opts): """remove dotfile from dotpath and from config""" paths = opts.remove_path @@ -853,6 +895,12 @@ def _exec_command(opts): LOG.dbg(f'running cmd: {command}') cmd_remove(opts) + elif opts.cmd_uninstall: + # uninstall dotfile + command = 'uninstall' + LOG.dbg(f'running cmd: {command}') + cmd_uninstall(opts) + except UndefinedException as exc: LOG.err(exc) ret = False diff --git a/dotdrop/installer.py b/dotdrop/installer.py index 775eb70c6..ec162376c 100644 --- a/dotdrop/installer.py +++ b/dotdrop/installer.py @@ -459,6 +459,8 @@ def _symlink(self, src, dst, actionexec=None, absolute=True): return False, 'aborted' # remove symlink + if self.backup and not os.path.isdir(dst): + self._backup(dst) overwrite = True try: removepath(dst) @@ -545,6 +547,7 @@ def _copy_file(self, templater, src, dst, content = None if is_template: # template the file + self.log.dbg(f'it is a template: {src}') saved = templater.add_tmp_vars(self._get_tmp_file_vars(src, dst)) try: content = templater.generate(src) @@ -666,6 +669,7 @@ def _write(self, src, dst, content=None, if os.path.lexists(dst): # file/symlink exists + self.log.dbg(f'file already exists on filesystem: {dst}') try: os.stat(dst) except OSError as exc: @@ -691,6 +695,8 @@ def _write(self, src, dst, content=None, if self.backup: self._backup(dst) + else: + self.log.dbg(f'file does not exist on filesystem: {dst}') # create hierarchy base = os.path.dirname(dst) diff --git a/dotdrop/options.py b/dotdrop/options.py index 2ea823146..113b3429d 100644 --- a/dotdrop/options.py +++ b/dotdrop/options.py @@ -68,6 +68,7 @@ dotdrop update [-VbfdkPz] [-c ] [-p ] [-w ] [-i ...] [...] dotdrop remove [-Vbfdk] [-c ] [-p ] [...] + dotdrop uninstall [-Vbfd] [-c ] [-p ] [...] dotdrop files [-VbTG] [-c ] [-p ] dotdrop detail [-Vb] [-c ] [-p ] [...] dotdrop profiles [-VbG] [-c ] @@ -340,6 +341,10 @@ def _apply_args_remove(self): self.remove_path = self.args[''] self.remove_iskey = self.args['--key'] + def _apply_args_uninstall(self): + """uninstall specifics""" + self.uninstall_key = self.args[''] + def _apply_args_detail(self): """detail specifics""" self.detail_keys = self.args[''] @@ -355,6 +360,7 @@ def _apply_args(self): self.cmd_update = self.args['update'] self.cmd_detail = self.args['detail'] self.cmd_remove = self.args['remove'] + self.cmd_uninstall = self.args['uninstall'] # adapt attributes based on arguments self.safe = not self.args['--force'] @@ -403,6 +409,9 @@ def _apply_args(self): # "remove" specifics self._apply_args_remove() + # "uninstall" specifics + self._apply_args_uninstall() + def _fill_attr(self): """create attributes from conf""" # defined variables diff --git a/dotdrop/uninstaller.py b/dotdrop/uninstaller.py new file mode 100644 index 000000000..e6c3c5d0c --- /dev/null +++ b/dotdrop/uninstaller.py @@ -0,0 +1,150 @@ +""" +author: deadc0de6 (https://github.com/deadc0de6) +Copyright (c) 2023, deadc0de6 + +handle the un-installation of dotfiles +""" + +import os +from dotdrop.logger import Logger +from dotdrop.utils import removepath + + +class Uninstaller: + """dotfile uninstaller""" + + def __init__(self, base='.', workdir='~/.config/dotdrop', + dry=False, safe=True, debug=False, + backup_suffix='.dotdropbak'): + """ + @base: directory path where to search for templates + @workdir: where to install template before symlinking + @dry: just simulate + @debug: enable debug + @backup_suffix: suffix for dotfile backup file + @safe: ask for any action + """ + base = os.path.expanduser(base) + base = os.path.normpath(base) + self.base = base + workdir = os.path.expanduser(workdir) + workdir = os.path.normpath(workdir) + self.workdir = workdir + self.dry = dry + self.safe = safe + self.debug = debug + self.backup_suffix = backup_suffix + self.log = Logger(debug=self.debug) + + def uninstall(self, src, dst, linktype): + """ + uninstall dst + @src: dotfile source path in dotpath + @dst: dotfile destination path in the FS + @linktype: linktypes.LinkTypes + + return + - True, None : success + - False, error_msg : error + """ + if not src or not dst: + self.log.dbg(f'cannot uninstall empty {src} or {dst}') + return True, None + + # ensure exists + path = os.path.expanduser(dst) + path = os.path.normpath(path) + path = path.rstrip(os.sep) + + if not os.path.isfile(path) and not os.path.isdir(path): + msg = f'cannot uninstall special file {path}' + return False, msg + + if not os.path.exists(path): + self.log.dbg(f'cannot uninstall non existing {path}') + return True, None + + msg = f'uninstalling \"{path}\" (link: {linktype})' + self.log.dbg(msg) + ret, msg = self._remove(path) + if ret: + if not self.dry: + self.log.sub(f'uninstall {dst}') + return ret, msg + + def _descend(self, dirpath): + ret = True + self.log.dbg(f'recursively uninstall {dirpath}') + for sub in os.listdir(dirpath): + subpath = os.path.join(dirpath, sub) + if os.path.isdir(subpath): + self.log.dbg(f'under {dirpath} uninstall dir {subpath}') + self._descend(subpath) + else: + self.log.dbg(f'under {dirpath} uninstall file {subpath}') + subret, _ = self._remove(subpath) + if not subret: + ret = False + + if not os.listdir(dirpath): + # empty + self.log.dbg(f'remove empty dir {dirpath}') + if self.dry: + self.log.dry(f'would \"rm -r {dirpath}\"') + return True, '' + return self._remove_path(dirpath) + self.log.dbg(f'not removing non-empty dir {dirpath}') + return ret, '' + + def _remove_path(self, path): + """remove a file""" + try: + removepath(path, self.log) + except OSError as exc: + err = f'removing \"{path}\" failed: {exc}' + return False, err + return True, '' + + def _remove(self, path): + """remove path""" + self.log.dbg(f'handling uninstall of {path}') + if path.endswith(self.backup_suffix): + self.log.dbg(f'skip {path} ignored') + return True, '' + backup = f'{path}{self.backup_suffix}' + if os.path.exists(backup): + self.log.dbg(f'backup exists for {path}: {backup}') + return self._replace(path, backup) + self.log.dbg(f'no backup file for {path}') + + if os.path.isdir(path): + self.log.dbg(f'{path} is a directory') + return self._descend(path) + + if self.dry: + self.log.dry(f'would \"rm {path}\"') + return True, '' + + msg = f'Remove {path}?' + if self.safe and not self.log.ask(msg): + return False, 'user refused' + self.log.dbg(f'removing {path}') + return self._remove_path(path) + + def _replace(self, path, backup): + """replace path by backup""" + if self.dry: + self.log.dry(f'would \"mv {backup} {path}\"') + return True, '' + + msg = f'Restore {path} from {backup}?' + if self.safe and not self.log.ask(msg): + return False, 'user refused' + + try: + self.log.dbg(f'mv {backup} {path}') + os.replace(backup, path) + except OSError as exc: + err = f'replacing \"{path}\" by \"{backup}\" failed: {exc}' + return False, err + return True, '' diff --git a/dotdrop/utils.py b/dotdrop/utils.py index 28e817eeb..ba97d6552 100644 --- a/dotdrop/utils.py +++ b/dotdrop/utils.py @@ -175,6 +175,8 @@ def removepath(path, logger=None): return LOG.err(err) raise OSError(err) + if logger: + logger.dbg(f'removing {path}') try: if os.path.islink(path) or os.path.isfile(path): os.unlink(path) diff --git a/scripts/check-syntax.sh b/scripts/check-syntax.sh index 63743d8ed..99cfca8ad 100755 --- a/scripts/check-syntax.sh +++ b/scripts/check-syntax.sh @@ -38,10 +38,12 @@ pyflakes --version # checking for TODO/FIXME echo "--------------------------------------" echo "checking for TODO/FIXME" -grep -rv 'TODO\|FIXME' dotdrop/ >/dev/null 2>&1 -grep -rv 'TODO\|FIXME' tests/ >/dev/null 2>&1 -grep -rv 'TODO\|FIXME' tests-ng/ >/dev/null 2>&1 -grep -rv 'TODO\|FIXME' scripts/ >/dev/null 2>&1 +set +e +grep -r 'TODO\|FIXME' dotdrop/ && exit 1 +grep -r 'TODO\|FIXME' tests/ && exit 1 +grep -r 'TODO\|FIXME' tests-ng/ && exit 1 +#grep -r 'TODO\|FIXME' scripts/ && exit 1 +set -e # PEP8 tests # W503: Line break occurred before a binary operator diff --git a/scripts/check_links.py b/scripts/check_links.py index 85e2eed4c..4ba0ec3fc 100755 --- a/scripts/check_links.py +++ b/scripts/check_links.py @@ -30,7 +30,10 @@ 302, ] IGNORES = [ - 'badgen.net', + 'badgen.net', +] +OK_WHEN_FORBIDDEN = [ + 'linux.die.net' ] IGNORE_GENERIC = [] USER_AGENT = ( @@ -103,6 +106,11 @@ def check_links(urls): # pylint: disable=W0703 except Exception: ret = 404 + if ret == 403 and hostname in OK_WHEN_FORBIDDEN: + msg = f' [{GREEN}OK-although-{ret}{RESET}]' + msg += f' {MAGENTA}{url}{RESET}' + print(msg) + continue if ret not in VALID_RET: msg = ( f' {YELLOW}[WARN]{RESET} HEAD {url} returned {ret}' diff --git a/tests-ng/actions-pre.sh b/tests-ng/actions-pre.sh index 8a3cf1a19..ab1e34cb3 100755 --- a/tests-ng/actions-pre.sh +++ b/tests-ng/actions-pre.sh @@ -30,7 +30,10 @@ echo -e "$(tput setaf 6)==> RUNNING $(basename "${BASH_SOURCE[0]}") <==$(tput sg # $2 path grep_or_fail() { - grep "${1}" "${2}" >/dev/null 2>&1 || (echo "pattern not found in ${2}" && exit 1) + if ! grep "${1}" "${2}" >/dev/null 2>&1; then + echo "pattern not found in ${2}" + exit 1 + fi } # the action temp diff --git a/tests-ng/backup.sh b/tests-ng/backup.sh new file mode 100755 index 000000000..ae6a58ae5 --- /dev/null +++ b/tests-ng/backup.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# author: deadc0de6 (https://github.com/deadc0de6) +# Copyright (c) 2023, deadc0de6 +# +# test for backups +# returns 1 in case of error +# + +## start-cookie +set -euo errtrace pipefail +cur=$(cd "$(dirname "${0}")" && pwd) +ddpath="${cur}/../" +PPATH="{PYTHONPATH:-}" +export PYTHONPATH="${ddpath}:${PPATH}" +altbin="python3 -m dotdrop.dotdrop" +if hash coverage 2>/dev/null; then + mkdir -p coverages/ + altbin="coverage run -p --data-file coverages/coverage --source=dotdrop -m dotdrop.dotdrop" +fi +bin="${DT_BIN:-${altbin}}" +# shellcheck source=tests-ng/helpers +source "${cur}"/helpers +echo -e "$(tput setaf 6)==> RUNNING $(basename "${BASH_SOURCE[0]}") <==$(tput sgr0)" +## end-cookie + +################################################################ +# this is the test +################################################################ +# $1 pattern +# $2 path +grep_or_fail() +{ + if ! grep "${1}" "${2}" >/dev/null 2>&1; then + echo "pattern \"${1}\" not found in ${2}" + exit 1 + fi +} + +# the dotfile source +tmps=$(mktemp -d --suffix='-dotdrop-tests-dotpath' || mktemp -d) +mkdir -p "${tmps}"/dotfiles +# the dotfile destination +tmpd=$(mktemp -d --suffix='-dotdrop-tests-dst' || mktemp -d) +tmpw=$(mktemp -d --suffix='-dotdrop-workdir' || mktemp -d) + +clear_on_exit "${tmps}" +clear_on_exit "${tmpd}" +clear_on_exit "${tmpw}" + +clear_dotpath() +{ + rm -rf "${tmps:?}"/dotfiles/* +} + +create_dotpath() +{ + # create the dotfiles in dotpath + echo "modified" > "${tmps}"/dotfiles/file + echo "{{@@ profile @@}}" > "${tmps}"/dotfiles/template + mkdir -p "${tmps}"/dotfiles/dir + echo "modified" > "${tmps}"/dotfiles/dir/sub + echo "{{@@ profile @@}}" > "${tmps}"/dotfiles/dir/template + mkdir -p "${tmps}"/dotfiles/tree + echo "modified" > "${tmps}"/dotfiles/tree/file + echo "{{@@ profile @@}}" > "${tmps}"/dotfiles/tree/template + mkdir -p "${tmps}"/dotfiles/tree/sub + echo "modified" > "${tmps}"/dotfiles/tree/sub/file + echo "{{@@ profile @@}}" > "${tmps}"/dotfiles/tree/sub/template +} + +clear_fs() +{ + rm -rf "${tmpd:?}"/* +} + +create_fs() +{ + # create the existing dotfiles in filesystem + echo "original" > "${tmpd}"/file + echo "original" > "${tmpd}"/template + mkdir -p "${tmpd}"/dir + echo "original" > "${tmpd}"/dir/sub + echo "original" > "${tmpd}"/dir/template + mkdir -p "${tmpd}"/tree + echo "original" > "${tmpd}"/tree/file + echo "original" > "${tmpd}"/tree/template + mkdir -p "${tmpd}"/tree/sub + echo "original" > "${tmpd}"/tree/sub/file + echo "original" > "${tmpd}"/tree/sub/template +} + +# create the config file +cfg="${tmps}/config.yaml" + +# $1: linktype +create_config() +{ + link_default="${1}" + link_file="${1}" + link_dir="${1}" + if [ "${link_default}" = "link_children" ]; then + link_file="nolink" + fi + cat > "${cfg}" << _EOF +config: + backup: true + create: true + dotpath: dotfiles + link_dotfile_default: ${link_default} + workdir: ${tmpw} +dotfiles: + f_file: + dst: ${tmpd}/file + src: file + link: ${link_file} + f_template: + dst: ${tmpd}/template + src: template + link: ${link_file} + d_dir: + dst: ${tmpd}/dir + src: dir + link: ${link_dir} + d_tree: + dst: ${tmpd}/tree + src: tree + link: ${link_dir} +profiles: + p1: + dotfiles: + - f_file + - f_template + - d_dir + - d_tree +_EOF + #cat ${cfg} +} + +# install nolink +pre="link:nolink" +create_config "nolink" +clear_dotpath +clear_fs +create_dotpath +create_fs +cd "${ddpath}" | ${bin} install -f -c "${cfg}" -p p1 --verbose + +# checks +[ ! -e "${tmpd}"/file.dotdropbak ] && echo "${pre} file backup not found" && exit 1 +[ ! -e "${tmpd}"/template.dotdropbak ] && echo "${pre} template backup not found" && exit 1 +[ ! -e "${tmpd}"/dir/sub.dotdropbak ] && echo "${pre} dir sub backup not found" && exit 1 +[ ! -e "${tmpd}"/dir/template.dotdropbak ] && echo "${pre} dir template backup not found" && exit 1 +[ ! -e "${tmpd}"/tree/file.dotdropbak ] && echo "${pre} tree file backup not found" && exit 1 +[ ! -e "${tmpd}"/tree/template.dotdropbak ] && echo "${pre} tree template backup not found" && exit 1 +[ ! -e "${tmpd}"/tree/sub/file.dotdropbak ] && echo "${pre} tree sub file backup not found" && exit 1 +[ ! -e "${tmpd}"/tree/sub/template.dotdropbak ] && echo "${pre} tree sub template backup not found" && exit 1 +grep_or_fail original "${tmpd}"/file.dotdropbak +grep_or_fail original "${tmpd}"/template.dotdropbak +grep_or_fail original "${tmpd}"/dir/sub.dotdropbak +grep_or_fail original "${tmpd}"/dir/template.dotdropbak +grep_or_fail original "${tmpd}"/tree/file.dotdropbak +grep_or_fail original "${tmpd}"/tree/template.dotdropbak +grep_or_fail original "${tmpd}"/tree/sub/file.dotdropbak +grep_or_fail original "${tmpd}"/tree/sub/template.dotdropbak +grep_or_fail p1 "${tmpd}"/template +grep_or_fail modified "${tmpd}"/dir/sub +grep_or_fail p1 "${tmpd}"/dir/template +grep_or_fail modified "${tmpd}"/tree/file +grep_or_fail p1 "${tmpd}"/tree/template +grep_or_fail modified "${tmpd}"/tree/sub/file +grep_or_fail p1 "${tmpd}"/tree/sub/template + +# install relative +pre="link:relative" +create_config "relative" +clear_dotpath +clear_fs +create_dotpath +create_fs +cd "${ddpath}" | ${bin} install -f -c "${cfg}" -p p1 --verbose + +# checks +[ ! -e "${tmpd}"/file.dotdropbak ] && echo "${pre} file backup not found" && exit 1 +[ ! -e "${tmpd}"/template.dotdropbak ] && echo "${pre} template backup not found" && exit 1 +grep_or_fail original "${tmpd}"/file.dotdropbak +grep_or_fail original "${tmpd}"/template.dotdropbak +grep_or_fail p1 "${tmpd}"/template +grep_or_fail modified "${tmpd}"/dir/sub +grep_or_fail p1 "${tmpd}"/dir/template +grep_or_fail modified "${tmpd}"/tree/file +grep_or_fail p1 "${tmpd}"/tree/template +grep_or_fail modified "${tmpd}"/tree/sub/file +grep_or_fail p1 "${tmpd}"/tree/sub/template + +# install absolute +pre="link:absolute" +create_config "absolute" +clear_dotpath +clear_fs +create_dotpath +create_fs +cd "${ddpath}" | ${bin} install -f -c "${cfg}" -p p1 --verbose + +# checks +[ ! -e "${tmpd}"/file.dotdropbak ] && echo "${pre} file backup not found" && exit 1 +[ ! -e "${tmpd}"/template.dotdropbak ] && echo "${pre} template backup not found" && exit 1 +grep_or_fail original "${tmpd}"/file.dotdropbak +grep_or_fail original "${tmpd}"/template.dotdropbak +grep_or_fail p1 "${tmpd}"/template +grep_or_fail modified "${tmpd}"/dir/sub +grep_or_fail p1 "${tmpd}"/dir/template +grep_or_fail modified "${tmpd}"/tree/file +grep_or_fail p1 "${tmpd}"/tree/template +grep_or_fail modified "${tmpd}"/tree/sub/file +grep_or_fail p1 "${tmpd}"/tree/sub/template + +# install link_children +pre="link:link_children" +create_config "link_children" +clear_dotpath +clear_fs +create_dotpath +create_fs +cd "${ddpath}" | ${bin} install -f -c "${cfg}" -p p1 --verbose + +# checks +[ ! -e "${tmpd}"/file.dotdropbak ] && echo "${pre} file backup not found" && exit 1 +[ ! -e "${tmpd}"/template.dotdropbak ] && echo "${pre} template backup not found" && exit 1 +[ ! -e "${tmpd}"/dir/sub.dotdropbak ] && echo "${pre} dir sub backup not found" && exit 1 +[ ! -e "${tmpd}"/dir/template.dotdropbak ] && echo "${pre} dir template backup not found" && exit 1 +[ ! -e "${tmpd}"/tree/file.dotdropbak ] && echo "${pre} tree file backup not found" && exit 1 +[ ! -e "${tmpd}"/tree/template.dotdropbak ] && echo "${pre} tree template backup not found" && exit 1 +grep_or_fail original "${tmpd}"/file.dotdropbak +grep_or_fail original "${tmpd}"/template.dotdropbak +grep_or_fail original "${tmpd}"/dir/sub.dotdropbak +grep_or_fail original "${tmpd}"/dir/template.dotdropbak +grep_or_fail original "${tmpd}"/tree/file.dotdropbak +grep_or_fail original "${tmpd}"/tree/template.dotdropbak +grep_or_fail p1 "${tmpd}"/template +grep_or_fail modified "${tmpd}"/dir/sub +grep_or_fail p1 "${tmpd}"/dir/template +grep_or_fail modified "${tmpd}"/tree/file +grep_or_fail p1 "${tmpd}"/tree/template +grep_or_fail modified "${tmpd}"/tree/sub/file +grep_or_fail p1 "${tmpd}"/tree/sub/template + +echo "OK" +exit 0 diff --git a/tests-ng/helpers b/tests-ng/helpers index c7180dabd..de35ca0d1 100644 --- a/tests-ng/helpers +++ b/tests-ng/helpers @@ -6,7 +6,10 @@ # # for i in *.sh; do ./$i >/dev/null 2>&1; find /tmp/ -maxdepth 1 -type f -iname 'tmp*' >> /tmp/$i.log; find /tmp/ -maxdepth 1 -type d -iname 'tmp.*-dotdrop-tests' >> /tmp/$i.log; find /tmp/ -maxdepth 1 -type d -iname 'dotdrop-*' >> /tmp/$i.log; wc -l /tmp/$i.log; [ "`wc -l /tmp/$i.log | awk '{print $1}'`" -gt "0" ] && break; done -declare -a to_be_cleared +set -euo errtrace pipefail + +#declare -a to_be_cleared +to_be_cleared=() # add a file/directory to be cleared # on exit @@ -109,7 +112,8 @@ fi # workdir tricks # when tests are called without using the # top level tests.sh script which sets the workdir -if [ -z "${DOTDROP_WORKDIR}" ]; then +DD_WORKDIR=${DOTDROP_WORKDIR:-} +if [ -z "${DD_WORKDIR}" ]; then _workdir="/tmp/dotdrop-test-workdir" export DOTDROP_WORKDIR="${_workdir}" clear_on_exit "${_workdir}" diff --git a/tests-ng/uninstall.sh b/tests-ng/uninstall.sh new file mode 100755 index 000000000..67e74392d --- /dev/null +++ b/tests-ng/uninstall.sh @@ -0,0 +1,303 @@ +#!/usr/bin/env bash +# author: deadc0de6 (https://github.com/deadc0de6) +# Copyright (c) 2023, deadc0de6 +# +# test uninstall (no symlink) +# returns 1 in case of error +# + +## start-cookie +set -euo errtrace pipefail +cur=$(cd "$(dirname "${0}")" && pwd) +ddpath="${cur}/../" +PPATH="{PYTHONPATH:-}" +export PYTHONPATH="${ddpath}:${PPATH}" +altbin="python3 -m dotdrop.dotdrop" +if hash coverage 2>/dev/null; then + mkdir -p coverages/ + altbin="coverage run -p --data-file coverages/coverage --source=dotdrop -m dotdrop.dotdrop" +fi +bin="${DT_BIN:-${altbin}}" +# shellcheck source=tests-ng/helpers +source "${cur}"/helpers +echo -e "$(tput setaf 6)==> RUNNING $(basename "${BASH_SOURCE[0]}") <==$(tput sgr0)" +## end-cookie + +################################################################ +# this is the test +################################################################ +# $1 pattern +# $2 path +grep_or_fail() +{ + if ! grep "${1}" "${2}" >/dev/null 2>&1; then + echo "${PRE} pattern \"${1}\" not found in ${2}" + exit 1 + fi +} + +# $1: basedir +# $2: content +create_hierarchy() +{ + echo "${2}" > "${1}"/x + mkdir -p "${1}"/y + echo "${2}" > "${1}"/y/file + mkdir -p "${1}"/y/subdir + echo "${2}" > "${1}"/y/subdir/subfile + echo "profile: ${PRO_TEMPL}" > "${1}"/t + mkdir -p "${1}"/z + echo "profile t1: ${PRO_TEMPL}" > "${1}"/z/t1 + echo "profile t2: ${PRO_TEMPL}" > "${1}"/z/t2 + echo "${2}" > "${1}"/z/file + echo "trans:${PRO_TEMPL}" > "${1}"/trans +} + +# $1: basedir +clean_hierarchy() +{ + rm -rf "${1:?}"/* +} + +uninstall_with_link() +{ + set -e + + LINK_TYPE="${DOTDROP_TEST_NG_UNINSTALL_LINK_TYPE:-nolink}" + PRE="[link:${LINK_TYPE}] ERROR" + PRO_TEMPL="{{@@ profile @@}}" + DT_ARG="--verbose" + + # dotdrop directory + basedir=$(mktemp -d --suffix='-dotdrop-tests' || mktemp -d) + mkdir -p "${basedir}"/dotfiles + echo "[+] dotdrop dir: ${basedir}" + echo "[+] dotpath dir: ${basedir}/dotfiles" + tmpd=$(mktemp -d --suffix='-dotdrop-tests' || mktemp -d) + tmpw=$(mktemp -d --suffix='-dotdrop-workdir' || mktemp -d) + + clear_on_exit "${basedir}/dotfiles" + clear_on_exit "${tmpd}" + clear_on_exit "${tmpw}" + + file_link="${LINK_TYPE}" + dir_link="${LINK_TYPE}" + if [ "${LINK_TYPE}" = "link_children" ]; then + file_link="absolute" + fi + + # create the config file + cfg="${basedir}/config.yaml" + cat > "${cfg}" << _EOF +config: + backup: true + create: true + dotpath: dotfiles + link_dotfile_default: ${LINK_TYPE} + workdir: ${tmpw} +dotfiles: + f_x: + src: x + dst: ${tmpd}/x + link: ${file_link} + d_y: + src: y + dst: ${tmpd}/y + link: ${dir_link} + f_t: + src: t + dst: ${tmpd}/t + link: ${file_link} + d_z: + src: z + dst: ${tmpd}/z + link: ${dir_link} + f_trans: + src: trans + dst: ${tmpd}/trans + link: ${file_link} +profiles: + p1: + dotfiles: + - f_x + - d_y + - f_t + - d_z + - f_trans +_EOF + + ######################### + ## no original + ######################### + + create_hierarchy "${basedir}/dotfiles" "modified" + + # install + echo "[+] install (1)" + ( \ + cd "${ddpath}" && ${bin} install -c "${cfg}" -f -p p1 | grep '^4 dotfile(s) installed.$' \ + ) + + # tests + [ ! -e "${tmpd}"/x ] && echo "${PRE} f_x not installed" && exit 1 + [ ! -e "${tmpd}"/y/file ] && echo "${PRE} d_y not installed" && exit 1 + [ ! -e "${tmpd}"/y/subdir/subfile ] && echo "${PRE} d_y not installed" && exit 1 + [ ! -e "${tmpd}"/t ] && echo "${PRE} f_t not installed" && exit 1 + [ ! -e "${tmpd}"/z/t1 ] && echo "${PRE} d_z t1 not installed" && exit 1 + [ ! -e "${tmpd}"/z/t2 ] && echo "${PRE} d_z t2 not installed" && exit 1 + [ ! -e "${tmpd}"/z/file ] && echo "${PRE} d_z file not installed" && exit 1 + [ ! -e "${tmpd}"/trans ] && echo "${PRE} f_trans file not installed" && exit 1 + grep_or_fail 'modified' "${tmpd}"/x + grep_or_fail 'modified' "${tmpd}"/y/file + grep_or_fail 'profile: p1' "${tmpd}"/t + grep_or_fail 'profile t1: p1' "${tmpd}"/z/t1 + grep_or_fail 'profile t2: p1' "${tmpd}"/z/t2 + grep_or_fail 'modified' "${tmpd}"/z/file + grep_or_fail 'trans:p1' "${tmpd}"/trans + + # uninstall + echo "[+] uninstall (1)" + ( \ + cd "${ddpath}" && ${bin} uninstall -c "${cfg}" -f -p p1 "${DT_ARG}" \ + ) + [ "$?" != "0" ] && exit 1 + + # tests + [ ! -d "${basedir}"/dotfiles ] && echo "${PRE} dotpath removed" && exit 1 + [ -e "${tmpd}"/x ] && echo "${PRE} f_x not uninstalled" && exit 1 + [ -d "${tmpd}"/y ] && echo "${PRE} d_y dir not uninstalled" && exit 1 + [ -e "${tmpd}"/y/file ] && echo "${PRE} d_y file not uninstalled" && exit 1 + [ -e "${tmpd}"/y/subdir/subfile ] && echo "${PRE} d_y subfile not uninstalled" && exit 1 + [ -e "${tmpd}"/t ] && echo "${PRE} f_t not uninstalled" && exit 1 + [ -e "${tmpd}"/z/t1 ] && echo "${PRE} d_z subfile t1 not uninstalled" && exit 1 + [ -e "${tmpd}"/z/t2 ] && echo "${PRE} d_z subfile t2 not uninstalled" && exit 1 + [ -e "${tmpd}"/z/file ] && echo "${PRE} d_z subfile file not uninstalled" && exit 1 + [ -e "${tmpd}"/trans ] && echo "${PRE} f_trans file not uninstalled" && exit 1 + + # test workdir is empty + if [ -n "$(ls -A "${tmpw}")" ]; then + echo "${PRE} workdir (1) is not empty" + echo "---" + ls -A "${tmpw}" + echo "---" + exit 1 + fi + + ######################### + ## with original + ######################### + # clean + clean_hierarchy "${tmpd}" + clean_hierarchy "${basedir}"/dotfiles + + # recreate + create_hierarchy "${basedir}"/dotfiles "modified" + create_hierarchy "${tmpd}" "original" + + # install + echo "[+] install (2)" + cd "${ddpath}" | ${bin} install -c "${cfg}" -f -p p1 | grep '^4 dotfile(s) installed.$' + + # tests + [ ! -e "${tmpd}"/x ] && echo "${PRE} f_x not installed" && exit 1 + [ ! -e "${tmpd}"/x.dotdropbak ] && echo "${PRE} f_x backup not created" && exit 1 + [ ! -d "${tmpd}"/y ] && echo "${PRE} d_y not installed" && exit 1 + [ ! -e "${tmpd}"/y/file ] && echo "${PRE} d_y file not installed" && exit 1 + [ "${LINK_TYPE}" = "nolink" ] && [ ! -e "${tmpd}"/y/file.dotdropbak ] && echo "${PRE} d_y backup file not created" && exit 1 + [ ! -e "${tmpd}"/y/subdir/subfile ] && echo "${PRE} d_y subfile not installed" && exit 1 + [ "${LINK_TYPE}" = "nolink" ] && [ ! -e "${tmpd}"/y/subdir/subfile.dotdropbak ] && echo "${PRE} d_y subfile backup not created" && exit 1 + [ ! -e "${tmpd}"/t ] && echo "${PRE} f_t not installed" && exit 1 + [ ! -e "${tmpd}"/t.dotdropbak ] && echo "${PRE} f_t backup not created" && exit 1 + [ ! -e "${tmpd}"/z/t1 ] && echo "${PRE} d_z t1 not installed" && exit 1 + [ "${LINK_TYPE}" = "nolink" ] && [ ! -e "${tmpd}"/z/t1.dotdropbak ] && echo "${PRE} d_z t1 backup not created" && exit 1 + [ ! -e "${tmpd}"/z/t2 ] && echo "${PRE} d_z t2 not installed" && exit 1 + [ "${LINK_TYPE}" = "nolink" ] && [ ! -e "${tmpd}"/z/t2.dotdropbak ] && echo "${PRE} d_z t2 backup not created" && exit 1 + [ ! -e "${tmpd}"/z/file ] && echo "${PRE} d_z file not installed" && exit 1 + [ "${LINK_TYPE}" = "nolink" ] && [ ! -e "${tmpd}"/z/file.dotdropbak ] && echo "${PRE} d_z backup file not created" && exit 1 + [ ! -e "${tmpd}"/trans ] && echo "${PRE} f_trans file not installed" && exit 1 + [ ! -e "${tmpd}"/trans.dotdropbak ] && echo "${PRE} f_trans backup file not created" && exit 1 + grep_or_fail 'modified' "${tmpd}"/x + grep_or_fail 'modified' "${tmpd}"/y/file + grep_or_fail 'profile: p1' "${tmpd}"/t + grep_or_fail 'profile t1: p1' "${tmpd}"/z/t1 + grep_or_fail 'profile t2: p1' "${tmpd}"/z/t2 + grep_or_fail 'modified' "${tmpd}"/z/file + grep_or_fail 'trans:p1' "${tmpd}"/trans + + # uninstall + echo "[+] uninstall (2)" + ( \ + cd "${ddpath}" && ${bin} uninstall -c "${cfg}" -f -p p1 "${DT_ARG}" \ + ) + [ "$?" != "0" ] && exit 1 + + # tests + [ ! -d "${basedir}"/dotfiles ] && echo "${PRE} dotpath removed" && exit 1 + [ ! -e "${tmpd}"/x ] && echo "${PRE} f_x backup not restored" && exit 1 + [ -e "${tmpd}"/x.dotdropbak ] && echo "${PRE} f_x backup not removed" && exit 1 + [ "${LINK_TYPE}" = "nolink" ] && [ ! -d "${tmpd}"/y ] && echo "${PRE} d_y backup not restored" && exit 1 + [ "${LINK_TYPE}" = "nolink" ] && [ ! -e "${tmpd}"/y/file ] && echo "${PRE} d_y file backup not restored" && exit 1 + [ -e "${tmpd}"/y/file.dotdropbak ] && echo "${PRE} d_y backup not removed" && exit 1 + [ "${LINK_TYPE}" = "nolink" ] && [ ! -e "${tmpd}"/y/subdir/subfile ] && echo "${PRE} d_y sub backup not restored" && exit 1 + [ -e "${tmpd}"/y/subdir/subfile.dotdropbak ] && echo "${PRE} d_y sub backup not removed" && exit 1 + [ ! -e "${tmpd}"/t ] && echo "${PRE} f_t not restored" && exit 1 + [ -e "${tmpd}"/t.dotdropbak ] && echo "${PRE} f_t backup not removed" && exit 1 + [ "${LINK_TYPE}" = "nolink" ] && [ ! -e "${tmpd}"/z/t1 ] && echo "${PRE} d_z t1 not restore" && exit 1 + [ -e "${tmpd}"/z/t1.dotdropbak ] && echo "${PRE} d_z t1 backup not removed" && exit 1 + [ "${LINK_TYPE}" = "nolink" ] && [ ! -e "${tmpd}"/z/t2 ] && echo "${PRE} d_z t2 not restored" && exit 1 + [ -e "${tmpd}"/z/t2.dotdropbak ] && echo "${PRE} d_z t2 backup not removed" && exit 1 + [ "${LINK_TYPE}" = "nolink" ] && [ ! -e "${tmpd}"/z/file ] && echo "${PRE} d_z file not restored" && exit 1 + [ -e "${tmpd}"/z/file.dotdropbak ] && echo "${PRE} d_z file backup not removed" && exit 1 + [ ! -e "${tmpd}"/trans ] && echo "${PRE} f_trans backup not restored" && exit 1 + [ -e "${tmpd}"/trans.dotdropbak ] && echo "${PRE} f_trans backup not removed" && exit 1 + + grep_or_fail 'original' "${tmpd}"/x + [ "${LINK_TYPE}" = "nolink" ] && grep_or_fail 'original' "${tmpd}"/y/file + grep_or_fail "profile: ${PRO_TEMPL}" "${tmpd}/t" + [ "${LINK_TYPE}" = "nolink" ] && grep_or_fail "profile t1: ${PRO_TEMPL}" "${tmpd}/z/t1" + [ "${LINK_TYPE}" = "nolink" ] && grep_or_fail "profile t2: ${PRO_TEMPL}" "${tmpd}/z/t2" + [ "${LINK_TYPE}" = "nolink" ] && grep_or_fail 'original' "${tmpd}"/z/file + grep_or_fail "trans:${PRO_TEMPL}" "${tmpd}"/trans + + echo "testing workdir..." + + # test workdir is empty + if [ -n "$(ls -A "${tmpw}")" ]; then + echo "${PRE} workdir (2) - ${tmpw} - is not empty" + ls -r "${tmpw}" + exit 1 + fi + + echo "${PRE} done OK" +} + +export DOTDROP_TEST_NG_UNINSTALL_DDPATH="${ddpath}" +export DOTDROP_TEST_NG_UNINSTALL_BIN="${bin}" +export DOTDROP_TEST_NG_CUR="${cur}" + +export DOTDROP_TEST_NG_UNINSTALL_LINK_TYPE="nolink" +# shellcheck source=uninstall_ +echo "[+] testing uninstall link:${DOTDROP_TEST_NG_UNINSTALL_LINK_TYPE}..." +if ! uninstall_with_link; then exit 1; fi +echo "[+] uninstall link:${DOTDROP_TEST_NG_UNINSTALL_LINK_TYPE} OK" + +export DOTDROP_TEST_NG_UNINSTALL_LINK_TYPE="absolute" +# shellcheck source=uninstall_ +echo "[+] testing uninstall link:${DOTDROP_TEST_NG_UNINSTALL_LINK_TYPE}..." +if ! uninstall_with_link; then exit 1; fi +echo "[+] uninstall link:${DOTDROP_TEST_NG_UNINSTALL_LINK_TYPE} OK" + +export DOTDROP_TEST_NG_UNINSTALL_LINK_TYPE="relative" +# shellcheck source=uninstall_ +echo "[+] testing uninstall link:${DOTDROP_TEST_NG_UNINSTALL_LINK_TYPE}..." +if ! uninstall_with_link; then exit 1; fi +echo "[+] uninstall link:${DOTDROP_TEST_NG_UNINSTALL_LINK_TYPE} OK" + +export DOTDROP_TEST_NG_UNINSTALL_LINK_TYPE="link_children" +# shellcheck source=uninstall_ +echo "[+] testing uninstall link:${DOTDROP_TEST_NG_UNINSTALL_LINK_TYPE}..." +if ! uninstall_with_link; then exit 1; fi +echo "[+] uninstall link:${DOTDROP_TEST_NG_UNINSTALL_LINK_TYPE} OK" + +echo "OK" +exit 0 \ No newline at end of file diff --git a/tests/helpers.py b/tests/helpers.py index ac58e7f98..97197c4b4 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -148,6 +148,7 @@ def _fake_args(): args['profiles'] = False args['files'] = False args['install'] = False + args['uninstall'] = False args['compare'] = False args['import'] = False args['update'] = False