From 6b324328b6bdcd046751a213d044fa5d8beb81cb Mon Sep 17 00:00:00 2001 From: Ivan Brkanac Date: Fri, 10 Feb 2017 14:18:33 +0100 Subject: [PATCH 01/13] Start from previous work by ibeex on #90 For ibeex's original code see: https://github.com/anishathalye/dotbot/issues/90#issuecomment-279323350 --- dotbot/plugins/link.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/dotbot/plugins/link.py b/dotbot/plugins/link.py index 152fd91..a0109d7 100644 --- a/dotbot/plugins/link.py +++ b/dotbot/plugins/link.py @@ -2,6 +2,7 @@ import glob import os import shutil import sys +import errno from ..plugin import Plugin from ..util import shell_command @@ -120,6 +121,28 @@ class Link(Plugin): self._log.debug("Test '%s' returned false" % command) return ret == 0 + def _move(self, link_name, path): + success = True + source = os.path.expanduser(link_name) + destination = os.path.join(self._context.base_directory(), path) + if os.path.isdir(source): + shutil.copytree(source, destination) + shutil.rmtree(source, ignore_errors=True) + elif os.path.isfile(source): + try: + os.makedirs(os.path.split(destination)[0]) + except OSError as exception: + if exception.errno != errno.EEXIST: + success = False + # TODO: check what is eerno.EEXIST and replace above with pass if relevant + shutil.copy(source, destination) + os.unlink(source) + else: + self._log.warning("Config file missing %s" % source) + return False + self._log.info("Moved existing config %s" % source) + return success + def _default_source(self, destination, source): if source is None: basename = os.path.basename(destination) From 4121157c8d144c2bb512aa7d30a0468b12f880c9 Mon Sep 17 00:00:00 2001 From: c-c-k Date: Mon, 17 Jul 2023 19:38:44 +0300 Subject: [PATCH 02/13] Refactor Link._move into Link._backup --- dotbot/plugins/link.py | 52 +++++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/dotbot/plugins/link.py b/dotbot/plugins/link.py index a0109d7..0c4431f 100644 --- a/dotbot/plugins/link.py +++ b/dotbot/plugins/link.py @@ -2,7 +2,7 @@ import glob import os import shutil import sys -import errno +import datetime from ..plugin import Plugin from ..util import shell_command @@ -121,26 +121,46 @@ class Link(Plugin): self._log.debug("Test '%s' returned false" % command) return ret == 0 - def _move(self, link_name, path): + def _backup(self, link_name, backup_root): + if not backup_root: + return False success = True source = os.path.expanduser(link_name) - destination = os.path.join(self._context.base_directory(), path) - if os.path.isdir(source): - shutil.copytree(source, destination) - shutil.rmtree(source, ignore_errors=True) + timestamp = datetime.datetime.now().strftime(".%Y%m%dT%H%M%S.%f") + backup_root = os.path.expandvars(os.path.expanduser(backup_root)) + backup_root = os.path.normpath(os.path.join(self._context.base_directory(), backup_root)) + destination, ext = os.path.splitext( + os.path.normpath(os.path.splitdrive(source)[1]).strip("/") + ) + destination = os.path.join(backup_root, destination + timestamp + ext) + success &= self._create(destination) + if os.path.islink(source): + try: + shutil.copy2(source, destination, follow_symlinks=False) + except OSError: + self._log.warning("Failed to backup symlink %s to %s" % (source, destination)) + success = False + else: + self._log.lowinfo("Performed backup of symlink %s to %s" % (source, destination)) + elif os.path.isdir(source): + try: + shutil.copytree(source, destination, symlinks=True) + except OSError: + self._log.warning("Failed to backup directory %s to %s" % (source, destination)) + success = False + else: + self._log.lowinfo("performed backup of directory %s to %s" % (source, destination)) elif os.path.isfile(source): try: - os.makedirs(os.path.split(destination)[0]) - except OSError as exception: - if exception.errno != errno.EEXIST: - success = False - # TODO: check what is eerno.EEXIST and replace above with pass if relevant - shutil.copy(source, destination) - os.unlink(source) + shutil.copy2(source, destination) + except OSError: + self._log.warning("Failed to backup file %s to %s" % (source, destination)) + success = False + else: + self._log.lowinfo("Performed backup of file %s to %s" % (source, destination)) else: - self._log.warning("Config file missing %s" % source) - return False - self._log.info("Moved existing config %s" % source) + self._log.warning("Failed to backup irregular file %s" % source) + success = False return success def _default_source(self, destination, source): From 1f65810dc513513f58cb8423fde47a4f911b9a14 Mon Sep 17 00:00:00 2001 From: c-c-k Date: Tue, 18 Jul 2023 06:08:15 +0300 Subject: [PATCH 03/13] Add call to Link._backup into Link._delete --- dotbot/plugins/link.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/dotbot/plugins/link.py b/dotbot/plugins/link.py index 0c4431f..adfc0bc 100644 --- a/dotbot/plugins/link.py +++ b/dotbot/plugins/link.py @@ -246,7 +246,7 @@ class Link(Plugin): self._log.lowinfo("Creating directory %s" % parent) return success - def _delete(self, source, path, relative, canonical_path, force): + def _delete(self, source, path, relative, canonical_path, force, backup_root): success = True source = os.path.join(self._context.base_directory(canonical_path=canonical_path), source) fullpath = os.path.abspath(os.path.expanduser(path)) @@ -256,8 +256,13 @@ class Link(Plugin): self._exists(path) and not self._is_link(path) ): removed = False + if backup_root: + backed_up = self._backup(path, backup_root) try: - if os.path.islink(fullpath): + if backup_root and not backed_up: + self._log.warning("Skipping removal of %s due to failed backup" % path) + success = False + elif os.path.islink(fullpath): os.unlink(fullpath) removed = True elif force: From 28780a3d0efb8307f4343c03835d1aae94dc90d7 Mon Sep 17 00:00:00 2001 From: c-c-k Date: Wed, 19 Jul 2023 19:27:02 +0300 Subject: [PATCH 04/13] Add backup_root to Link._process_links --- dotbot/plugins/link.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/dotbot/plugins/link.py b/dotbot/plugins/link.py index adfc0bc..66e38ec 100644 --- a/dotbot/plugins/link.py +++ b/dotbot/plugins/link.py @@ -33,6 +33,7 @@ class Link(Plugin): canonical_path = defaults.get("canonicalize", defaults.get("canonicalize-path", True)) force = defaults.get("force", False) relink = defaults.get("relink", False) + backup_root = defaults.get("backup-root", None) create = defaults.get("create", False) use_glob = defaults.get("glob", False) base_prefix = defaults.get("prefix", "") @@ -48,6 +49,7 @@ class Link(Plugin): ) force = source.get("force", force) relink = source.get("relink", relink) + backup_root = source.get("backup-root", backup_root) create = source.get("create", create) use_glob = source.get("glob", use_glob) base_prefix = source.get("prefix", base_prefix) @@ -85,6 +87,7 @@ class Link(Plugin): relative, canonical_path, force, + backup_root, ) success &= self._link( glob_full_item, @@ -107,7 +110,9 @@ class Link(Plugin): self._log.warning("Nonexistent source %s -> %s" % (destination, path)) continue if force or relink: - success &= self._delete(path, destination, relative, canonical_path, force) + success &= self._delete( + path, destination, relative, canonical_path, force, backup_root + ) success &= self._link(path, destination, relative, canonical_path, ignore_missing) if success: self._log.info("All links have been set up") From 0e4b0aec56dc19e74fb7c887b6fad3f56f958112 Mon Sep 17 00:00:00 2001 From: c-c-k Date: Fri, 28 Jul 2023 20:00:57 +0300 Subject: [PATCH 05/13] Add: test_link_force_overwrite_source --- tests/test_link.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_link.py b/tests/test_link.py index e577f4d..34ba1ec 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -175,6 +175,32 @@ def test_link_force_overwrite_symlink(home, dotfiles, run_dotbot): assert os.path.isfile(os.path.join(home, ".dir", "f")) +def test_link_force_overwrite_source(home, dotfiles, run_dotbot): + """Verify force overwrites a symlinked directory.""" + + os.symlink(home, os.path.join(home, ".link")) + with open(os.path.join(home, "file"), "w") as file: + file.write("") + os.mkdir(os.path.join(home, "dir")) + dotfiles.write("dir/f") + + config = [ + { + "link": { + "~/.link": {"path": "dir", "force": True}, + "~/file": {"path": "dir", "force": True}, + "~/dir": {"path": "dir", "force": True}, + } + } + ] + dotfiles.write_config(config) + run_dotbot() + + assert os.path.isfile(os.path.join(home, ".link", "f")) + assert os.path.isfile(os.path.join(home, "file", "f")) + assert os.path.isfile(os.path.join(home, "dir", "f")) + + def test_link_glob_1(home, dotfiles, run_dotbot): """Verify globbing works.""" From 7d315f7b9f9e620bda333fd86c6b513cabc7d0f2 Mon Sep 17 00:00:00 2001 From: c-c-k Date: Fri, 28 Jul 2023 20:03:15 +0300 Subject: [PATCH 06/13] Add: test_link_force_backups_source --- tests/test_link.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_link.py b/tests/test_link.py index 34ba1ec..4279759 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -201,6 +201,50 @@ def test_link_force_overwrite_source(home, dotfiles, run_dotbot): assert os.path.isfile(os.path.join(home, "dir", "f")) +def test_link_force_backups_source(home, dotfiles, run_dotbot): + """Verify force backups a symlinked directory.""" + + os.symlink(home, os.path.join(home, ".link")) + with open(os.path.join(home, "file"), "w") as file: + file.write("apple") + os.mkdir(os.path.join(home, "dir")) + dotfiles.write("dir/f", "peach") + + backup_root = os.path.join(dotfiles.directory, "backup") + config = [ + { + "link": { + "~/.link": {"path": "dir", "force": True, "backup-root": backup_root}, + "~/file": {"path": "dir", "force": True, "backup-root": backup_root}, + "~/dir": {"path": "dir", "force": True, "backup-root": backup_root}, + } + } + ] + dotfiles.write_config(config) + run_dotbot() + + backup_home = os.path.join(backup_root, home[1:]) + dir_items = os.listdir(backup_home) + test_items = (".link", "file", "dir") + backuped = { + test_item: dir_item + for dir_item in dir_items + for test_item in test_items + if test_item in dir_item + } + + assert set(backuped.keys()) == set(test_items) + assert os.path.islink(os.path.join(backup_home, backuped[".link"])) + assert home in os.readlink(os.path.join(backup_home, backuped[".link"])) + assert os.path.isfile(os.path.join(backup_home, backuped["file"])) + with open(os.path.join(backup_home, backuped["file"])) as file: + assert file.read() == "apple" + assert os.path.isdir(os.path.join(backup_home, backuped["dir"])) + for link in test_items: + with open(os.path.join(home, link, "f")) as file: + assert file.read() == "peach" + + def test_link_glob_1(home, dotfiles, run_dotbot): """Verify globbing works.""" From 3debb974d50521fd993e19b3ece2ea662309bb83 Mon Sep 17 00:00:00 2001 From: c-c-k Date: Fri, 28 Jul 2023 20:03:53 +0300 Subject: [PATCH 07/13] Add: test_link_force_aborts_on_failed_backup --- tests/test_link.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_link.py b/tests/test_link.py index 4279759..4ca65e9 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -245,6 +245,38 @@ def test_link_force_backups_source(home, dotfiles, run_dotbot): assert file.read() == "peach" +def test_link_force_aborts_on_failed_backup(home, dotfiles, run_dotbot): + """Verify force is aborted if backup fails.""" + + os.symlink(home, os.path.join(home, ".link")) + with open(os.path.join(home, "file"), "w") as file: + file.write("apple") + os.mkdir(os.path.join(home, "dir")) + dotfiles.write("backup") + dotfiles.write("dir/f") + + backup_root = os.path.join(dotfiles.directory, "backup") + config = [ + { + "link": { + "~/.link": {"path": "dir", "force": True, "backup-root": backup_root}, + "~/file": {"path": "dir", "force": True, "backup-root": backup_root}, + "~/dir": {"path": "dir", "force": True, "backup-root": backup_root}, + } + } + ] + dotfiles.write_config(config) + with pytest.raises(SystemExit): + run_dotbot() + + assert os.path.islink(os.path.join(home, ".link")) + assert home in os.readlink(os.path.join(home, ".link")) + assert os.path.isfile(os.path.join(home, "file")) + with open(os.path.join(home, "file")) as file: + assert file.read() == "apple" + assert os.path.isdir(os.path.join(home, "dir")) + + def test_link_glob_1(home, dotfiles, run_dotbot): """Verify globbing works.""" From 2600826c281b6ba1318981a9037de56f0e0cc679 Mon Sep 17 00:00:00 2001 From: c-c-k Date: Fri, 28 Jul 2023 20:04:36 +0300 Subject: [PATCH 08/13] Add: test_link_relink_backups_symlink --- tests/test_link.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_link.py b/tests/test_link.py index 4ca65e9..90aa705 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -1011,6 +1011,32 @@ def test_link_relink_overwrite_symlink(home, dotfiles, run_dotbot): assert file.read() == "apple" +def test_link_relink_backups_symlink(home, dotfiles, run_dotbot): + """Verify relink backups symlinks.""" + + dotfiles.write("f", "apple") + with open(os.path.join(home, "f"), "w") as file: + file.write("grape") + os.symlink(os.path.join(home, "f"), os.path.join(home, ".f")) + + backup_root = os.path.join(dotfiles.directory, "backup") + dotfiles.write_config( + [{"link": {"~/.f": {"path": "f", "relink": True, "backup-root": backup_root}}}] + ) + run_dotbot() + + backup_home = os.path.join(backup_root, home[1:]) + assert os.path.isdir(backup_home) + dir_items = os.listdir(backup_home) + assert len(dir_items) == 1 + backuped = os.path.join(backup_home, dir_items[0]) + assert os.path.islink(backuped) + with open(backuped, "r") as file: + assert file.read() == "grape" + with open(os.path.join(home, ".f"), "r") as file: + assert file.read() == "apple" + + def test_link_relink_relative_leaves_file(home, dotfiles, run_dotbot): """Verify relink relative does not incorrectly relink file.""" From 595a445f4d1576ca7bf1623d71357affbbf6e97d Mon Sep 17 00:00:00 2001 From: c-c-k Date: Fri, 28 Jul 2023 20:05:20 +0300 Subject: [PATCH 09/13] Add: test_link_relink_aborts_on_failed_backup --- tests/test_link.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_link.py b/tests/test_link.py index 90aa705..db55f5f 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -1037,6 +1037,26 @@ def test_link_relink_backups_symlink(home, dotfiles, run_dotbot): assert file.read() == "apple" +def test_link_relink_aborts_on_failed_backup(home, dotfiles, run_dotbot): + """Verify relink is aborted if backup fails.""" + + dotfiles.write("f", "apple") + with open(os.path.join(home, "f"), "w") as file: + file.write("grape") + os.symlink(os.path.join(home, "f"), os.path.join(home, ".f")) + dotfiles.write("backup") + + backup_root = os.path.join(dotfiles.directory, "backup") + dotfiles.write_config( + [{"link": {"~/.f": {"path": "f", "relink": True, "backup-root": backup_root}}}] + ) + with pytest.raises(SystemExit): + run_dotbot() + + with open(os.path.join(home, ".f"), "r") as file: + assert file.read() == "grape" + + def test_link_relink_relative_leaves_file(home, dotfiles, run_dotbot): """Verify relink relative does not incorrectly relink file.""" From d0509148b79630a6d295aae4fb657d0e33ef5de2 Mon Sep 17 00:00:00 2001 From: c-c-k Date: Fri, 28 Jul 2023 20:06:34 +0300 Subject: [PATCH 10/13] Add: test_no_backup_when_no_force_relink --- tests/test_link.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_link.py b/tests/test_link.py index db55f5f..a73bf44 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -277,6 +277,33 @@ def test_link_force_aborts_on_failed_backup(home, dotfiles, run_dotbot): assert os.path.isdir(os.path.join(home, "dir")) +def test_no_backup_when_no_force_relink(home, dotfiles, run_dotbot): + """Verify backup is a no-op without an accompanying force or relink.""" + + os.symlink(home, os.path.join(home, ".link")) + with open(os.path.join(home, "file"), "w") as file: + file.write("apple") + os.mkdir(os.path.join(home, "dir")) + dotfiles.write("dir/f") + + backup_root = os.path.join(dotfiles.directory, "backup") + config = [ + { + "link": { + "~/.link": {"path": "dir", "backup-root": backup_root}, + "~/file": {"path": "dir", "backup-root": backup_root}, + "~/dir": {"path": "dir", "backup-root": backup_root}, + "~/.new": {"path": "dir", "backup-root": backup_root}, + } + } + ] + dotfiles.write_config(config) + with pytest.raises(SystemExit): + run_dotbot() + + assert not os.path.exists(backup_root) + + def test_link_glob_1(home, dotfiles, run_dotbot): """Verify globbing works.""" From 52a9c347d1fef3f41d884da1f87e4f91027c954f Mon Sep 17 00:00:00 2001 From: c-c-k Date: Fri, 28 Jul 2023 20:07:05 +0300 Subject: [PATCH 11/13] Add: test_backup_is_sequentially_differentiated --- tests/test_link.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_link.py b/tests/test_link.py index a73bf44..c48acb4 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -1084,6 +1084,34 @@ def test_link_relink_aborts_on_failed_backup(home, dotfiles, run_dotbot): assert file.read() == "grape" +def test_backup_is_sequentially_differetiated(home, dotfiles, run_dotbot): + """Verify that backups do not overwrite each other and get sequentially different names.""" + + with open(os.path.join(home, "f"), "w") as file: + file.write("grape") + os.symlink(os.path.join(home, "f"), os.path.join(home, ".f")) + dotfiles.write("f", "apple") + dotfiles.write("dir/f", "peach") + + backup_root = os.path.join(dotfiles.directory, "backup") + dotfiles.write_config( + [ + {"link": {"~/.f": {"path": "f", "relink": True, "backup-root": backup_root}}}, + {"link": {"~/.f": {"path": "dir/f", "relink": True, "backup-root": backup_root}}}, + ] + ) + run_dotbot() + + backup_home = os.path.join(backup_root, home[1:]) + dir_items = sorted(os.listdir(backup_home)) + with open(os.path.join(backup_home, dir_items[0])) as file: + assert file.read() == "grape" + with open(os.path.join(backup_home, dir_items[1])) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".f"), "r") as file: + assert file.read() == "peach" + + def test_link_relink_relative_leaves_file(home, dotfiles, run_dotbot): """Verify relink relative does not incorrectly relink file.""" From 91b44857788e7e2788904e3fbc5fd0507dd15d30 Mon Sep 17 00:00:00 2001 From: c-c-k Date: Fri, 28 Jul 2023 07:59:57 +0300 Subject: [PATCH 12/13] Add: test_backup_mirrors_absolute_path --- tests/test_link.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_link.py b/tests/test_link.py index c48acb4..76cfda8 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -201,6 +201,20 @@ def test_link_force_overwrite_source(home, dotfiles, run_dotbot): assert os.path.isfile(os.path.join(home, "dir", "f")) +def test_backup_mirrors_absolute_path(home, dotfiles, run_dotbot): + """Verify backup path from backup dir is same as original path from root.""" + + os.symlink(home, os.path.join(home, ".f")) + dotfiles.write("f") + + backup_root = os.path.join(dotfiles.directory, "backup") + config = [{"link": {"~/.f": {"path": "f", "force": True, "backup-root": backup_root}}}] + dotfiles.write_config(config) + run_dotbot() + + assert os.path.isdir(os.path.join(backup_root, home[1:])) + + def test_link_force_backups_source(home, dotfiles, run_dotbot): """Verify force backups a symlinked directory.""" From 3b26851e8e2a88667d7c65fcb2f99ecc491e627d Mon Sep 17 00:00:00 2001 From: c-c-k Date: Fri, 28 Jul 2023 19:14:53 +0300 Subject: [PATCH 13/13] Add: readme entry for `backup-root` --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2af6ed4..0727083 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ mapped to extended configuration dictionaries. | `create` | When true, create parent directories to the link as needed. (default: false) | | `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) | +| `backup-root` | In case of `force` or `relink` backup the old target to `backup-root` (default: no backup) | | `relative` | Use a relative path to the source when creating the symlink (default: false, absolute links) | | `canonicalize` | Resolve any symbolic links encountered in the source to symlink to the canonical path (default: true, real paths) | | `if` | Execute this in your `$SHELL` and only link if it is successful. |