parent
b8891c5fb7
commit
73b1b758d5
3 changed files with 130 additions and 8 deletions
|
@ -160,7 +160,8 @@ can also be configured via setting [defaults](#defaults).
|
||||||
|
|
||||||
### Link
|
### Link
|
||||||
|
|
||||||
Link commands specify how files and directories should be symbolically linked.
|
Link commands specify how files and directories should be linked.
|
||||||
|
Symlinks are created by default, but hardlinks are also supported.
|
||||||
If desired, items can be specified to be forcibly linked, overwriting existing
|
If desired, items can be specified to be forcibly linked, overwriting existing
|
||||||
files if necessary. Environment variables in paths are automatically expanded.
|
files if necessary. Environment variables in paths are automatically expanded.
|
||||||
|
|
||||||
|
@ -177,11 +178,12 @@ 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 link, the same as in the shortcut syntax (default: null, automatic (see below)) |
|
||||||
|
| `type` | The type of link to create. If specified, must be either `symlink` or `hardlink`. (default: `symlink`) |
|
||||||
| `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) |
|
| `relative` | When creating a symlink, use a relative path to the source. (default: false, absolute links) |
|
||||||
| `canonicalize` | Resolve any symbolic links encountered in the source to symlink to the canonical path (default: true, real paths) |
|
| `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. |
|
| `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) |
|
| `ignore-missing` | Do not fail if the source is missing and create the link anyway (default: false) |
|
||||||
|
|
|
@ -27,11 +27,19 @@ class Link(Plugin):
|
||||||
def _process_links(self, links: Any) -> bool:
|
def _process_links(self, links: Any) -> bool:
|
||||||
success = True
|
success = True
|
||||||
defaults = self._context.defaults().get("link", {})
|
defaults = self._context.defaults().get("link", {})
|
||||||
|
|
||||||
|
# Validate the default link type before looping.
|
||||||
|
link_type = defaults.get("type", "symlink")
|
||||||
|
if link_type not in {"symlink", "hardlink"}:
|
||||||
|
self._log.warning(f"The default link type is not recognized: '{link_type}'")
|
||||||
|
return False
|
||||||
|
|
||||||
for destination, source in links.items():
|
for destination, source in links.items():
|
||||||
destination = os.path.expandvars(destination) # noqa: PLW2901
|
destination = os.path.expandvars(destination) # noqa: PLW2901
|
||||||
relative = defaults.get("relative", False)
|
relative = defaults.get("relative", False)
|
||||||
# support old "canonicalize-path" key for compatibility
|
# support old "canonicalize-path" key for compatibility
|
||||||
canonical_path = defaults.get("canonicalize", defaults.get("canonicalize-path", True))
|
canonical_path = defaults.get("canonicalize", defaults.get("canonicalize-path", True))
|
||||||
|
link_type = defaults.get("type", "symlink")
|
||||||
force = defaults.get("force", False)
|
force = defaults.get("force", False)
|
||||||
relink = defaults.get("relink", False)
|
relink = defaults.get("relink", False)
|
||||||
create = defaults.get("create", False)
|
create = defaults.get("create", False)
|
||||||
|
@ -45,6 +53,12 @@ class Link(Plugin):
|
||||||
test = source.get("if", test)
|
test = source.get("if", test)
|
||||||
relative = source.get("relative", relative)
|
relative = source.get("relative", relative)
|
||||||
canonical_path = source.get("canonicalize", source.get("canonicalize-path", canonical_path))
|
canonical_path = source.get("canonicalize", source.get("canonicalize-path", canonical_path))
|
||||||
|
link_type = source.get("type", link_type)
|
||||||
|
if link_type not in {"symlink", "hardlink"}:
|
||||||
|
msg = f"The link type is not recognized: '{link_type}'"
|
||||||
|
self._log.warning(msg)
|
||||||
|
success = False
|
||||||
|
continue
|
||||||
force = source.get("force", force)
|
force = source.get("force", force)
|
||||||
relink = source.get("relink", relink)
|
relink = source.get("relink", relink)
|
||||||
create = source.get("create", create)
|
create = source.get("create", create)
|
||||||
|
@ -87,6 +101,7 @@ class Link(Plugin):
|
||||||
relative=relative,
|
relative=relative,
|
||||||
canonical_path=canonical_path,
|
canonical_path=canonical_path,
|
||||||
ignore_missing=ignore_missing,
|
ignore_missing=ignore_missing,
|
||||||
|
link_type=link_type,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if create:
|
if create:
|
||||||
|
@ -104,7 +119,12 @@ class Link(Plugin):
|
||||||
path, destination, relative=relative, canonical_path=canonical_path, force=force
|
path, destination, relative=relative, canonical_path=canonical_path, force=force
|
||||||
)
|
)
|
||||||
success &= self._link(
|
success &= self._link(
|
||||||
path, destination, relative=relative, canonical_path=canonical_path, ignore_missing=ignore_missing
|
path,
|
||||||
|
destination,
|
||||||
|
relative=relative,
|
||||||
|
canonical_path=canonical_path,
|
||||||
|
ignore_missing=ignore_missing,
|
||||||
|
link_type=link_type,
|
||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
self._log.info("All links have been set up")
|
self._log.info("All links have been set up")
|
||||||
|
@ -230,7 +250,16 @@ class Link(Plugin):
|
||||||
destination_dir = os.path.dirname(destination)
|
destination_dir = os.path.dirname(destination)
|
||||||
return os.path.relpath(source, destination_dir)
|
return os.path.relpath(source, destination_dir)
|
||||||
|
|
||||||
def _link(self, source: str, link_name: str, *, relative: bool, canonical_path: bool, ignore_missing: bool) -> bool:
|
def _link(
|
||||||
|
self,
|
||||||
|
source: str,
|
||||||
|
link_name: str,
|
||||||
|
*,
|
||||||
|
relative: bool,
|
||||||
|
canonical_path: bool,
|
||||||
|
ignore_missing: bool,
|
||||||
|
link_type: str,
|
||||||
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Links link_name to source.
|
Links link_name to source.
|
||||||
|
|
||||||
|
@ -249,11 +278,14 @@ class Link(Plugin):
|
||||||
# destination directory
|
# destination directory
|
||||||
elif not self._exists(link_name) and (ignore_missing or self._exists(absolute_source)):
|
elif not self._exists(link_name) and (ignore_missing or self._exists(absolute_source)):
|
||||||
try:
|
try:
|
||||||
|
if link_type == "symlink":
|
||||||
os.symlink(source, destination)
|
os.symlink(source, destination)
|
||||||
|
else: # link_type == "hardlink"
|
||||||
|
os.link(absolute_source, destination)
|
||||||
except OSError:
|
except OSError:
|
||||||
self._log.warning(f"Linking failed {link_name} -> {source}")
|
self._log.warning(f"Linking failed {link_name} -> {source}")
|
||||||
else:
|
else:
|
||||||
self._log.lowinfo(f"Creating link {link_name} -> {source}")
|
self._log.lowinfo(f"Creating {link_type} {link_name} -> {source}")
|
||||||
success = True
|
success = True
|
||||||
elif self._exists(link_name) and not self._is_link(link_name):
|
elif self._exists(link_name) and not self._is_link(link_name):
|
||||||
self._log.warning(f"{link_name} already exists but is a regular file or directory")
|
self._log.warning(f"{link_name} already exists but is a regular file or directory")
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from typing import Callable, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
@ -1000,3 +1000,91 @@ def test_link_defaults_2(home: str, dotfiles: Dotfiles, run_dotbot: Callable[...
|
||||||
|
|
||||||
with open(os.path.join(home, ".f")) as file:
|
with open(os.path.join(home, ".f")) as file:
|
||||||
assert file.read() == "apple"
|
assert file.read() == "apple"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"config",
|
||||||
|
[
|
||||||
|
pytest.param([{"link": {"~/.f": "f"}}], id="unspecified"),
|
||||||
|
pytest.param(
|
||||||
|
[{"link": {"~/.f": {"path": "f", "type": "symlink"}}}],
|
||||||
|
id="specified",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
[
|
||||||
|
{"defaults": {"link": {"type": "symlink"}}},
|
||||||
|
{"link": {"~/.f": "f"}},
|
||||||
|
],
|
||||||
|
id="symlink set for all links by default",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_link_type_symlink(
|
||||||
|
config: List[Dict[str, Any]], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
|
||||||
|
) -> None:
|
||||||
|
"""Verify that symlinks are created by default, and when specified."""
|
||||||
|
|
||||||
|
dotfiles.write("f", "apple")
|
||||||
|
dotfiles.write_config(config)
|
||||||
|
run_dotbot()
|
||||||
|
|
||||||
|
assert os.path.islink(os.path.join(home, ".f"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"config",
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
[{"link": {"~/.f": {"path": "f", "type": "hardlink"}}}],
|
||||||
|
id="specified",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
[
|
||||||
|
{"defaults": {"link": {"type": "hardlink"}}},
|
||||||
|
{"link": {"~/.f": "f"}},
|
||||||
|
],
|
||||||
|
id="hardlink set for all links by default",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_link_type_hardlink(
|
||||||
|
config: List[Dict[str, Any]], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
|
||||||
|
) -> None:
|
||||||
|
"""Verify that hardlinks are created when specified."""
|
||||||
|
|
||||||
|
dotfiles.write("f", "apple")
|
||||||
|
assert os.stat(os.path.join(dotfiles.directory, "f")).st_nlink == 1
|
||||||
|
dotfiles.write_config(config)
|
||||||
|
run_dotbot()
|
||||||
|
|
||||||
|
assert not os.path.islink(os.path.join(home, ".f"))
|
||||||
|
assert os.stat(os.path.join(dotfiles.directory, "f")).st_nlink == 2
|
||||||
|
assert os.stat(os.path.join(home, ".f")).st_nlink == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"config",
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
[{"defaults": {"link": {"type": "default-bogus"}}, "link": {}}],
|
||||||
|
id="default link type not recognized",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
[{"link": {"~/.f": {"type": "specified-bogus"}}}],
|
||||||
|
id="specified link type not recognized",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unknown_link_type(
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
config: List[Dict[str, Any]],
|
||||||
|
dotfiles: Dotfiles,
|
||||||
|
run_dotbot: Callable[..., None],
|
||||||
|
) -> None:
|
||||||
|
"""Verify that unknown link types are rejected."""
|
||||||
|
|
||||||
|
dotfiles.write_config(config)
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
run_dotbot()
|
||||||
|
stdout, _ = capsys.readouterr()
|
||||||
|
assert "link type is not recognized" in stdout
|
||||||
|
|
Loading…
Reference in a new issue