From f309ff80f8e76fb5c95184cf7ab06673ea61934f Mon Sep 17 00:00:00 2001 From: deadc0de6 Date: Fri, 20 Jul 2018 23:35:44 +0200 Subject: [PATCH 1/6] reimplement update --- dotdrop/dotdrop.py | 57 ++---------- dotdrop/updater.py | 210 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 48 deletions(-) create mode 100644 dotdrop/updater.py diff --git a/dotdrop/dotdrop.py b/dotdrop/dotdrop.py index c605db0..10bccaa 100644 --- a/dotdrop/dotdrop.py +++ b/dotdrop/dotdrop.py @@ -15,6 +15,7 @@ from dotdrop.version import __version__ as VERSION from dotdrop.logger import Logger from dotdrop.templategen import Templategen from dotdrop.installer import Installer +from dotdrop.updater import Updater from dotdrop.dotfile import Dotfile from dotdrop.config import Cfg from dotdrop.utils import * @@ -39,7 +40,7 @@ Usage: dotdrop import [-ldVb] [-c ] [-p ] ... dotdrop compare [-Vb] [-c ] [-p ] [-o ] [--files=] - dotdrop update [-fdVb] [-c ] + dotdrop update [-fdVb] [-c ] [-p ] ... dotdrop listfiles [-Vb] [-c ] [-p ] dotdrop list [-Vb] [-c ] dotdrop --help @@ -212,52 +213,12 @@ def compare(opts, conf, tmp, focus=None): return ret -def update(opts, conf, path): - """update the dotfile from path""" - if not os.path.lexists(path): - LOG.err('\"{}\" does not exist!'.format(path)) - return False - home = os.path.expanduser(TILD) - path = os.path.expanduser(path) - path = os.path.expandvars(path) - # normalize the path - if path.startswith(home): - path = path.lstrip(home) - path = os.path.join(TILD, path) - dotfiles = conf.get_dotfiles(opts['profile']) - subs = [d for d in dotfiles if d.dst == path] - if not subs: - LOG.err('\"{}\" is not managed!'.format(path)) - return False - if len(subs) > 1: - found = ','.join([d.src for d in dotfiles]) - LOG.err('multiple dotfiles found: {}'.format(found)) - return False - dotfile = subs[0] - src = os.path.join(conf.abs_dotpath(opts['dotpath']), dotfile.src) - if os.path.isfile(src) and \ - Templategen.get_marker() in open(src, 'r').read(): - LOG.warn('\"{}\" uses template, please update manually'.format(src)) - return False - # Handle directory update - src_clean = src - if os.path.isdir(src): - src_clean = os.path.join(src, '..') - if samefile(src_clean, path): - # symlink loop - Log.err('dotfile points to itself: {}'.format(path)) - return False - cmd = ['cp', '-R', '-L', os.path.expanduser(path), src_clean] - if opts['dry']: - LOG.dry('would run: {}'.format(' '.join(cmd))) - else: - msg = 'Overwrite \"{}\" with \"{}\"?'.format(src, path) - if opts['safe'] and not LOG.ask(msg): - return False - else: - run(cmd, raw=False, debug=opts['debug']) - LOG.log('\"{}\" updated from \"{}\".'.format(src, path)) - return True +def update(opts, conf, paths): + """update the dotfile(s) from path(s)""" + updater = Updater(conf, opts['dotpath'], opts['dry'], + opts['safe'], opts['debug']) + for path in paths: + updater.update(path, opts['profile']) def importer(opts, conf, paths): @@ -396,7 +357,7 @@ def main(): elif args['update']: # update a dotfile - update(opts, conf, args['']) + update(opts, conf, args['']) except KeyboardInterrupt: LOG.err('interrupted') diff --git a/dotdrop/updater.py b/dotdrop/updater.py new file mode 100644 index 0000000..0880ff1 --- /dev/null +++ b/dotdrop/updater.py @@ -0,0 +1,210 @@ +""" +author: deadc0de6 (https://github.com/deadc0de6) +Copyright (c) 2017, deadc0de6 + +handle the update of dotfiles +""" + +import os +import shutil +import filecmp + +# local imports +from dotdrop.logger import Logger +from dotdrop.templategen import Templategen +import dotdrop.utils as utils + +TILD = '~' + + +class Updater: + + BACKUP_SUFFIX = '.dotdropbak' + + def __init__(self, conf, dotpath, dry, safe, debug): + self.home = os.path.expanduser(TILD) + self.conf = conf + self.dotpath = dotpath + self.dry = dry + self.safe = safe + self.debug = debug + self.log = Logger() + + def _normalize(self, path): + """normalize the path to match dotfile""" + path = os.path.expanduser(path) + path = os.path.expandvars(path) + + # normalize the path + if path.startswith(self.home): + path = path.lstrip(self.home) + path = os.path.join(TILD, path) + return path + + def _get_dotfile(self, path, profile): + """get the dotfile matching this path""" + dotfiles = self.conf.get_dotfiles(profile) + subs = [d for d in dotfiles if d.dst == path] + if not subs: + self.log.err('\"{}\" is not managed!'.format(path)) + return None + if len(subs) > 1: + found = ','.join([d.src for d in dotfiles]) + self.log.err('multiple dotfiles found: {}'.format(found)) + return None + return subs[0] + + def update(self, path, profile): + """update the dotfile installed on path""" + if not os.path.lexists(path): + self.log.err('\"{}\" does not exist!'.format(path)) + return False + left = self._normalize(path) + dotfile = self._get_dotfile(path, profile) + if not dotfile: + return False + if self.debug: + self.log.dbg('updating {} from {}'.format(dotfile, path)) + + right = os.path.join(self.conf.abs_dotpath(self.dotpath), dotfile.src) + # go through all files and update + if os.path.isdir(path): + return self._handle_dir(left, right) + return self._handle_file(left, right) + + def _is_template(self, path): + if Templategen.get_marker() not in open(path, 'r').read(): + return False + self.log.warn('{} uses template, update manually'.format(right)) + return True + + def _handle_file(self, left, right, compare=True): + """sync left (deployed file) and right (dotdrop dotfile)""" + if self.debug: + self.log.dbg('update for file {} and {}'.format(left, right)) + if self._is_template(right): + return False + if compare and filecmp.cmp(left, right, shallow=True): + # no difference + if self.debug: + self.log.dbg('identical files: {} and {}'.format(left, right)) + return True + if not self._overwrite(left, right): + return False + try: + if self.dry: + self.log.dry('would cp {} {}'.format(left, right)) + else: + if self.debug: + self.log.dbg('cp {} {}'.format(left, right)) + shutil.copyfile(left, right) + except IOError as e: + self.log.warn('{} update failed, do manually: {}'.format(left, e)) + return False + return True + + def _handle_dir(self, left, right): + """sync left (deployed dir) and right (dotdrop dir)""" + if self.debug: + self.log.dbg('handle update for dir {} to {}'.format(left, right)) + # find the difference + diff = filecmp.dircmp(left, right, ignore=None, hide=None) + # handle directories diff + # create dirs that don't exist in dotdrop + if self.debug: + self.log.dbg('handle dirs that do not exist in dotdrop') + for toadd in diff.left_only: + exist = os.path.join(left, toadd) + if not os.path.isdir(exist): + # ignore files for now + continue + # match to dotdrop dotpath + new = os.path.join(right, toadd) + if self.dry: + self.log.dry('would mkdir -p {}'.format(new)) + continue + if self.debug: + self.log.dbg('mkdir -p {}'.format(new)) + self._create_dirs(new) + + # remove dirs that don't exist in deployed version + if self.debug: + self.log.dbg('remove dirs that do not exist in deployed version') + for toremove in diff.right_only: + new = os.path.join(right, toremove) + if self.dry: + self.log.dry('would rm -r {}'.format(new)) + continue + if self.debug: + self.log.dbg('rm -r {}'.format(new)) + utils.remove(new) + + # handle files diff + # sync files that exist in both but are different + if self.debug: + self.log.dbg('sync files that exist in both but are different') + fdiff = diff.diff_files + fdiff.extend(diff.funny_files) + fdiff.extend(diff.common_funny) + for f in fdiff: + fleft = os.path.join(left, f) + fright = os.path.join(right, f) + if self.dry: + self.log.dry('would cp {} {}'.format(fleft, fright)) + continue + if self.debug: + self.log.dbg('cp {} {}'.format(fleft, fright)) + self._handle_file(fleft, fright, compare=False) + + # copy files that don't exist in dotdrop + if self.debug: + self.log.dbg('copy files not existing in dotdrop') + for toadd in diff.left_only: + exist = os.path.join(left, toadd) + if os.path.isdir(exist): + # ignore dirs, done above + continue + new = os.path.join(right, toadd) + if self.dry: + self.log.dry('would cp {} {}'.format(exist, new)) + continue + if self.debug: + self.log.dbg('cp {} {}'.format(exist, new)) + shutil.copyfile(exist, new) + + # remove files that don't exist in deployed version + if self.debug: + self.log.dbg('remove files that do not exist in deployed version') + for toremove in diff.right_only: + new = os.path.join(right, toremove) + if not os.path.exists(new): + continue + if os.path.isdir(new): + # ignore dirs, done above + continue + if self.dry: + self.log.dry('would rm {}'.format(new)) + continue + if self.debug: + self.log.dbg('rm {}'.format(new)) + utils.remove(new) + return True + + def _create_dirs(self, directory): + """mkdir -p """ + if os.path.exists(directory): + return True + if self.dry: + self.log.dry('would mkdir -p {}'.format(directory)) + return True + if self.debug: + self.log.dbg('mkdir -p {}'.format(directory)) + os.makedirs(directory) + return os.path.exists(directory) + + def _overwrite(self, src, dst): + """ask for overwritting""" + msg = 'Overwrite \"{}\" with \"{}\"?'.format(dst, src) + if self.safe and not self.log.ask(msg): + return False + return True From 1865c157253b061f3997287b0ddf8a24b98c245b Mon Sep 17 00:00:00 2001 From: deadc0de6 Date: Fri, 20 Jul 2018 23:35:57 +0200 Subject: [PATCH 2/6] add more tests for update --- tests-ng/update.sh | 128 +++++++++++++++++++++++++++++++++++++++++++ tests/test_import.py | 2 +- 2 files changed, 129 insertions(+), 1 deletion(-) create mode 100755 tests-ng/update.sh diff --git a/tests-ng/update.sh b/tests-ng/update.sh new file mode 100755 index 0000000..00ac397 --- /dev/null +++ b/tests-ng/update.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# author: deadc0de6 (https://github.com/deadc0de6) +# Copyright (c) 2017, deadc0de6 +# +# test updates +# returns 1 in case of error +# + +# exit on first error +set -e + +# all this crap to get current path +rl="readlink -f" +if ! ${rl} "${0}" >/dev/null 2>&1; then + rl="realpath" + + if ! hash ${rl}; then + echo "\"${rl}\" not found !" && exit 1 + fi +fi +cur=$(dirname "$(${rl} "${0}")") + +#hash dotdrop >/dev/null 2>&1 +#[ "$?" != "0" ] && echo "install dotdrop to run tests" && exit 1 + +#echo "called with ${1}" + +# dotdrop path can be pass as argument +ddpath="${cur}/../" +[ "${1}" != "" ] && ddpath="${1}" +[ ! -d ${ddpath} ] && echo "ddpath \"${ddpath}\" is not a directory" && exit 1 + +export PYTHONPATH="${ddpath}:${PYTHONPATH}" +bin="python3 -m dotdrop.dotdrop" + +echo "dotdrop path: ${ddpath}" +echo "pythonpath: ${PYTHONPATH}" + +# get the helpers +source ${cur}/helpers + +echo "RUNNING $(basename $BASH_SOURCE)" + +################################################################ +# this is the test +################################################################ + +# dotdrop directory +basedir=`mktemp -d` +echo "[+] dotdrop dir: ${basedir}" +echo "[+] dotpath dir: ${basedir}/dotfiles" + +# the dotfile to be imported +tmpd=`mktemp -d` + +# single file +echo 'unique' > ${tmpd}/uniquefile + +# hierarchy from https://pymotw.com/2/filecmp/ +# create the hierarchy +# for dir1 (originally imported directory)))) +mkdir ${tmpd}/dir1 +touch ${tmpd}/dir1/file_only_in_dir1 +mkdir -p ${tmpd}/dir1/dir_only_in_dir1 +mkdir -p ${tmpd}/dir1/common_dir +echo 'this file is the same' > ${tmpd}/dir1/common_file +echo 'in dir1' > ${tmpd}/dir1/not_the_same +echo 'This is a file in dir1' > ${tmpd}/dir1/file_in_dir1 +mkdir -p ${tmpd}/dir1/sub/sub2 +echo 'first' > ${tmpd}/dir1/sub/sub2/different +tree ${tmpd}/dir1 + +# create the hierarchy +# for dir2 (modified original for update) +mkdir ${tmpd}/dir2 +touch ${tmpd}/dir2/file_only_in_dir2 +mkdir -p ${tmpd}/dir2/dir_only_in_dir2 +mkdir -p ${tmpd}/dir2/common_dir +echo 'this file is the same' > ${tmpd}/dir2/common_file +echo 'in dir2' > ${tmpd}/dir2/not_the_same +mkdir -p ${tmpd}/dir2/file_in_dir1 +mkdir -p ${tmpd}/dir2/sub/sub2 +echo 'modified' > ${tmpd}/dir2/sub/sub2/different +mkdir -p ${tmpd}/dir2/new/new2 +tree ${tmpd}/dir2 + +# create the config file +cfg="${basedir}/config.yaml" +create_conf ${cfg} # sets token + +# import dir1 +echo "[+] import" +cd ${ddpath} | ${bin} import -c ${cfg} ${tmpd}/dir1 +cd ${ddpath} | ${bin} import -c ${cfg} ${tmpd}/uniquefile + +# let's see the dotpath +tree ${basedir}/dotfiles + +# change dir1 to dir2 in deployed +echo "[+] change dir" +rm -rf ${tmpd}/dir1 +mv ${tmpd}/dir2 ${tmpd}/dir1 +tree ${tmpd}/dir1 + +# change unique file +echo 'changed' > ${tmpd}/uniquefile + +# compare +#echo "[+] comparing" +#cd ${ddpath} | ${bin} compare -c ${cfg} + +# update +echo "[+] updating" +cd ${ddpath} | ${bin} update -c ${cfg} -f --verbose ${tmpd}/uniquefile ${tmpd}/dir1 + +# manually update +rm ${basedir}/dotfiles/${tmpd}/dir1/file_in_dir1 +mkdir -p ${basedir}/dotfiles/${tmpd}/dir1/file_in_dir1 + +# ensure changes applied correctly +diff ${tmpd}/dir1 ${basedir}/dotfiles/${tmpd}/dir1 +diff ${tmpd}/uniquefile ${basedir}/dotfiles/${tmpd}/uniquefile + +## CLEANING +rm -rf ${basedir} ${tmpd} + +echo "OK" +exit 0 diff --git a/tests/test_import.py b/tests/test_import.py index b60ec1f..0c35f36 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -202,7 +202,7 @@ class TestImport(unittest.TestCase): with open(dotfile1, 'w') as f: f.write('edited') opts['safe'] = False - update(opts, conf, dotfile1) + update(opts, conf, [dotfile1]) c2 = open(indt1, 'r').read() self.assertTrue(editcontent == c2) From cb99326717ed2ad54046a3e39c45146dafe43760 Mon Sep 17 00:00:00 2001 From: deadc0de6 Date: Fri, 20 Jul 2018 23:37:59 +0200 Subject: [PATCH 3/6] remove test debug --- tests-ng/update.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests-ng/update.sh b/tests-ng/update.sh index 00ac397..0b96620 100755 --- a/tests-ng/update.sh +++ b/tests-ng/update.sh @@ -68,7 +68,7 @@ echo 'in dir1' > ${tmpd}/dir1/not_the_same echo 'This is a file in dir1' > ${tmpd}/dir1/file_in_dir1 mkdir -p ${tmpd}/dir1/sub/sub2 echo 'first' > ${tmpd}/dir1/sub/sub2/different -tree ${tmpd}/dir1 +#tree ${tmpd}/dir1 # create the hierarchy # for dir2 (modified original for update) @@ -82,7 +82,7 @@ mkdir -p ${tmpd}/dir2/file_in_dir1 mkdir -p ${tmpd}/dir2/sub/sub2 echo 'modified' > ${tmpd}/dir2/sub/sub2/different mkdir -p ${tmpd}/dir2/new/new2 -tree ${tmpd}/dir2 +#tree ${tmpd}/dir2 # create the config file cfg="${basedir}/config.yaml" @@ -94,13 +94,13 @@ cd ${ddpath} | ${bin} import -c ${cfg} ${tmpd}/dir1 cd ${ddpath} | ${bin} import -c ${cfg} ${tmpd}/uniquefile # let's see the dotpath -tree ${basedir}/dotfiles +#tree ${basedir}/dotfiles # change dir1 to dir2 in deployed echo "[+] change dir" rm -rf ${tmpd}/dir1 mv ${tmpd}/dir2 ${tmpd}/dir1 -tree ${tmpd}/dir1 +#tree ${tmpd}/dir1 # change unique file echo 'changed' > ${tmpd}/uniquefile From 13abdf3357ae60ffa29890ad2465c794ec363a48 Mon Sep 17 00:00:00 2001 From: deadc0de6 Date: Fri, 20 Jul 2018 23:44:49 +0200 Subject: [PATCH 4/6] require confirmation for rm -r --- dotdrop/updater.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/dotdrop/updater.py b/dotdrop/updater.py index 0880ff1..98fd3b8 100644 --- a/dotdrop/updater.py +++ b/dotdrop/updater.py @@ -137,6 +137,8 @@ class Updater: continue if self.debug: self.log.dbg('rm -r {}'.format(new)) + if not self._confirm_rm_r(new): + continue utils.remove(new) # handle files diff @@ -208,3 +210,10 @@ class Updater: if self.safe and not self.log.ask(msg): return False return True + + def _confirm_rm_r(self, directory): + """ask for rm -r directory""" + msg = 'Recursively remove \"{}\"?'.format(directory) + if self.safe and not self.log.ask(msg): + return False + return True From cd4ac8414f14b6d757ae1d489a3d20d419727bd1 Mon Sep 17 00:00:00 2001 From: deadc0de6 Date: Fri, 20 Jul 2018 23:44:55 +0200 Subject: [PATCH 5/6] more tests --- tests-ng/update.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests-ng/update.sh b/tests-ng/update.sh index 0b96620..d7af450 100755 --- a/tests-ng/update.sh +++ b/tests-ng/update.sh @@ -67,6 +67,7 @@ echo 'this file is the same' > ${tmpd}/dir1/common_file echo 'in dir1' > ${tmpd}/dir1/not_the_same echo 'This is a file in dir1' > ${tmpd}/dir1/file_in_dir1 mkdir -p ${tmpd}/dir1/sub/sub2 +mkdir -p ${tmpd}/dir1/notindir2/notindir2 echo 'first' > ${tmpd}/dir1/sub/sub2/different #tree ${tmpd}/dir1 From ef4477f356c321758da6573d9f11a1b72f1afb59 Mon Sep 17 00:00:00 2001 From: deadc0de6 Date: Sat, 21 Jul 2018 00:35:04 +0200 Subject: [PATCH 6/6] ignore comparion . and .. --- dotdrop/updater.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotdrop/updater.py b/dotdrop/updater.py index 98fd3b8..a3f1923 100644 --- a/dotdrop/updater.py +++ b/dotdrop/updater.py @@ -108,7 +108,7 @@ class Updater: if self.debug: self.log.dbg('handle update for dir {} to {}'.format(left, right)) # find the difference - diff = filecmp.dircmp(left, right, ignore=None, hide=None) + diff = filecmp.dircmp(left, right, ignore=None) # handle directories diff # create dirs that don't exist in dotdrop if self.debug: