From 22a7cbb6f76e3be05f981e5f60d98b067083aca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petter=20Hanseg=C3=A5rd?= Date: Wed, 27 Sep 2023 21:31:47 +0200 Subject: [PATCH] Add backup option to link action Added backup option (defaults to false) which will cause dotbot to backup already existing files by renaming them with suffix .dotbot-backup. Added unit tests and updated README. --- README.md | 27 ++++++++-------- dotbot/plugins/link.py | 21 +++++++++++++ tests/test_link.py | 70 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 971f066..099bc6c 100644 --- a/README.md +++ b/README.md @@ -175,19 +175,20 @@ Link commands support an optional extended configuration. In this type of configuration, instead of specifying source locations directly, targets are mapped to extended configuration dictionaries. -| Parameter | Explanation | -| --- | --- | -| `path` | The source for the symlink, the same as in the shortcut syntax (default: null, automatic (see below)) | -| `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) | -| `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. | -| `ignore-missing` | Do not fail if the source is missing and create the link anyway (default: false) | -| `glob` | Treat `path` as a glob pattern, expanding patterns referenced below, linking all *files* matched. (default: false) | -| `exclude` | Array of glob patterns to remove from glob matches. Uses same syntax as `path`. Ignored if `glob` is `false`. (default: empty, keep all matches) | -| `prefix` | Prepend prefix prefix to basename of each file when linked, when `glob` is `true`. (default: '') | +| Parameter | Explanation | +|------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| +| `path` | The source for the symlink, the same as in the shortcut syntax (default: null, automatic (see below)) | +| `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` | Backup existing files/directories if they exist, creating a backup with suffix `.dotbot-backup (default: false) | +| `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. | +| `ignore-missing` | Do not fail if the source is missing and create the link anyway (default: false) | +| `glob` | Treat `path` as a glob pattern, expanding patterns referenced below, linking all *files* matched. (default: false) | +| `exclude` | Array of glob patterns to remove from glob matches. Uses same syntax as `path`. Ignored if `glob` is `false`. (default: empty, keep all matches) | +| `prefix` | Prepend prefix prefix to basename of each file when linked, when `glob` is `true`. (default: '') | When `glob: True`, Dotbot uses [glob.glob](https://docs.python.org/3/library/glob.html#glob.glob) to resolve glob paths, expanding Unix shell-style wildcards, which are **not** the same as regular expressions; Only the following are expanded: diff --git a/dotbot/plugins/link.py b/dotbot/plugins/link.py index 3a50a21..dd505a3 100644 --- a/dotbot/plugins/link.py +++ b/dotbot/plugins/link.py @@ -34,6 +34,7 @@ class Link(Plugin): relink = defaults.get("relink", False) create = defaults.get("create", False) use_glob = defaults.get("glob", False) + backup = defaults.get("backup", False) base_prefix = defaults.get("prefix", "") test = defaults.get("if", None) ignore_missing = defaults.get("ignore-missing", False) @@ -49,6 +50,7 @@ class Link(Plugin): relink = source.get("relink", relink) create = source.get("create", create) use_glob = source.get("glob", use_glob) + backup = source.get("backup", backup) base_prefix = source.get("prefix", base_prefix) ignore_missing = source.get("ignore-missing", ignore_missing) exclude_paths = source.get("exclude", exclude_paths) @@ -85,6 +87,8 @@ class Link(Plugin): canonical_path, force, ) + if backup: + success &= self._backup(glob_link_destination) success &= self._link( glob_full_item, glob_link_destination, @@ -107,6 +111,8 @@ class Link(Plugin): continue if force or relink: success &= self._delete(path, destination, relative, canonical_path, force) + if backup: + success &= self._backup(destination) success &= self._link(path, destination, relative, canonical_path, ignore_missing) if success: self._log.info("All links have been set up") @@ -197,6 +203,21 @@ class Link(Plugin): self._log.lowinfo("Creating directory %s" % parent) return success + def _backup(self, path): + success = True + if self._exists(path) and not self._is_link(path): + file_to_backup = os.path.join(os.path.expanduser(path)) + backup_path = file_to_backup + ".dotbot-backup" + self._log.debug(f"Try to backup file {file_to_backup} to {backup_path}") + try: + os.rename(file_to_backup, backup_path) + except OSError: + self._log.warning(f"Failed to backup file {file_to_backup} to {backup_path}") + success = False + else: + self._log.lowinfo(f"Backed up file {file_to_backup} to {backup_path}") + return success + def _delete(self, source, path, relative, canonical_path, force): success = True source = os.path.join(self._context.base_directory(canonical_path=canonical_path), source) diff --git a/tests/test_link.py b/tests/test_link.py index b769c92..3159c27 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -175,6 +175,76 @@ def test_link_force_overwrite_symlink(home, dotfiles, run_dotbot): assert os.path.isfile(os.path.join(home, ".dir", "f")) +def test_backup_is_created_if_destination_exists(home, dotfiles, run_dotbot): + """Verify that the backup directory is created if destination exists.""" + + os.mkdir(os.path.join(home, ".dir")) + dotfiles.write("dir") + + config = [{"link": {"~/.dir": {"path": "dir", "backup": True}}}] + dotfiles.write_config(config) + run_dotbot() + + assert os.path.exists(os.path.join(home, ".dir.dotbot-backup")) + + +def test_backup_file_is_created_if_destination_exists(home, dotfiles, run_dotbot): + """Verify that a backup file is created if destination exists.""" + + open(os.path.join(home, ".file"), "a").close() + dotfiles.write("file") + + config = [{"link": {"~/.file": {"path": "file", "backup": True}}}] + dotfiles.write_config(config) + run_dotbot() + + assert os.path.exists(os.path.join(home, ".file.dotbot-backup")) + + +def test_backup_file_not_created_if_link(home, dotfiles, run_dotbot): + """Verify that a backup file isn't created if destination is a symlink.""" + + open(os.path.join(home, "file"), "a").close() + dotfiles.write("file") + os.symlink(os.path.join(home, "file"), os.path.join(home, ".file")) + + config = [{"link": {"~/.file": {"path": "file", "backup": True}}}] + dotfiles.write_config(config) + with pytest.raises(SystemExit): + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".file.dotbot-backup")) + + +def test_backup_file_not_created_if_force(home, dotfiles, run_dotbot): + """Verify that a backup file is created while using force option.""" + + open(os.path.join(home, ".file"), "a").close() + dotfiles.write("file") + + config = [{"link": {"~/.file": {"path": "file", "backup": True, "force": True}}}] + dotfiles.write_config(config) + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".file.dotbot-backup")) + + +def test_backup_error_if_dest_already_exists(home, dotfiles, run_dotbot): + """Verify an error is thrown if the backup already exists.""" + + os.mkdir(os.path.join(home, ".dir")) + os.mkdir(os.path.join(home, ".dir.dotbot-backup")) + open(os.path.join(home, ".dir.dotbot-backup", "f"), "a").close() + dotfiles.write("dir") + + config = [{"link": {"~/.dir": {"path": "dir", "backup": True}}}] + dotfiles.write_config(config) + with pytest.raises(SystemExit): + run_dotbot() + + assert os.path.exists(os.path.join(home, ".dir.dotbot-backup", "f")) + + def test_link_glob_1(home, dotfiles, run_dotbot): """Verify globbing works."""