mirror of
1
0
Fork 0

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.
This commit is contained in:
Petter Hansegård 2023-09-27 21:31:47 +02:00
parent 3f9e409669
commit 22a7cbb6f7
3 changed files with 105 additions and 13 deletions

View File

@ -175,19 +175,20 @@ Link commands support an optional extended configuration. In this type of
configuration, instead of specifying source locations directly, targets are configuration, instead of specifying source locations directly, targets are
mapped to extended configuration dictionaries. mapped to extended configuration dictionaries.
| Parameter | Explanation | | Parameter | Explanation |
| --- | --- | |------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|
| `path` | The source for the symlink, the same as in the shortcut syntax (default: null, automatic (see below)) | | `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) | | `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) | | `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) | | `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) | | `backup` | Backup existing files/directories if they exist, creating a backup with suffix `.dotbot-backup (default: false) |
| `canonicalize` | Resolve any symbolic links encountered in the source to symlink to the canonical path (default: true, real paths) | | `relative` | Use a relative path to the source when creating the symlink (default: false, absolute links) |
| `if` | Execute this in your `$SHELL` and only link if it is successful. | | `canonicalize` | Resolve any symbolic links encountered in the source to symlink to the canonical path (default: true, real paths) |
| `ignore-missing` | Do not fail if the source is missing and create the link anyway (default: false) | | `if` | Execute this in your `$SHELL` and only link if it is successful. |
| `glob` | Treat `path` as a glob pattern, expanding patterns referenced below, linking all *files* matched. (default: false) | | `ignore-missing` | Do not fail if the source is missing and create the link anyway (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) | | `glob` | Treat `path` as a glob pattern, expanding patterns referenced below, linking all *files* matched. (default: false) |
| `prefix` | Prepend prefix prefix to basename of each file when linked, when `glob` is `true`. (default: '') | | `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: 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:

View File

@ -34,6 +34,7 @@ class Link(Plugin):
relink = defaults.get("relink", False) relink = defaults.get("relink", False)
create = defaults.get("create", False) create = defaults.get("create", False)
use_glob = defaults.get("glob", False) use_glob = defaults.get("glob", False)
backup = defaults.get("backup", False)
base_prefix = defaults.get("prefix", "") base_prefix = defaults.get("prefix", "")
test = defaults.get("if", None) test = defaults.get("if", None)
ignore_missing = defaults.get("ignore-missing", False) ignore_missing = defaults.get("ignore-missing", False)
@ -49,6 +50,7 @@ class Link(Plugin):
relink = source.get("relink", relink) relink = source.get("relink", relink)
create = source.get("create", create) create = source.get("create", create)
use_glob = source.get("glob", use_glob) use_glob = source.get("glob", use_glob)
backup = source.get("backup", backup)
base_prefix = source.get("prefix", base_prefix) base_prefix = source.get("prefix", base_prefix)
ignore_missing = source.get("ignore-missing", ignore_missing) ignore_missing = source.get("ignore-missing", ignore_missing)
exclude_paths = source.get("exclude", exclude_paths) exclude_paths = source.get("exclude", exclude_paths)
@ -85,6 +87,8 @@ class Link(Plugin):
canonical_path, canonical_path,
force, force,
) )
if backup:
success &= self._backup(glob_link_destination)
success &= self._link( success &= self._link(
glob_full_item, glob_full_item,
glob_link_destination, glob_link_destination,
@ -107,6 +111,8 @@ class Link(Plugin):
continue continue
if force or relink: if force or relink:
success &= self._delete(path, destination, relative, canonical_path, force) 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) success &= self._link(path, destination, relative, canonical_path, ignore_missing)
if success: if success:
self._log.info("All links have been set up") self._log.info("All links have been set up")
@ -197,6 +203,21 @@ class Link(Plugin):
self._log.lowinfo("Creating directory %s" % parent) self._log.lowinfo("Creating directory %s" % parent)
return success 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): def _delete(self, source, path, relative, canonical_path, force):
success = True success = True
source = os.path.join(self._context.base_directory(canonical_path=canonical_path), source) source = os.path.join(self._context.base_directory(canonical_path=canonical_path), source)

View File

@ -175,6 +175,76 @@ def test_link_force_overwrite_symlink(home, dotfiles, run_dotbot):
assert os.path.isfile(os.path.join(home, ".dir", "f")) 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): def test_link_glob_1(home, dotfiles, run_dotbot):
"""Verify globbing works.""" """Verify globbing works."""