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 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
|
||||
files if necessary. Environment variables in paths are automatically expanded.
|
||||
|
||||
|
@ -177,11 +178,12 @@ 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)) |
|
||||
| `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) |
|
||||
| `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) |
|
||||
| `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) |
|
||||
| `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) |
|
||||
|
|
|
@ -27,11 +27,19 @@ class Link(Plugin):
|
|||
def _process_links(self, links: Any) -> bool:
|
||||
success = True
|
||||
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():
|
||||
destination = os.path.expandvars(destination) # noqa: PLW2901
|
||||
relative = defaults.get("relative", False)
|
||||
# support old "canonicalize-path" key for compatibility
|
||||
canonical_path = defaults.get("canonicalize", defaults.get("canonicalize-path", True))
|
||||
link_type = defaults.get("type", "symlink")
|
||||
force = defaults.get("force", False)
|
||||
relink = defaults.get("relink", False)
|
||||
create = defaults.get("create", False)
|
||||
|
@ -45,6 +53,12 @@ class Link(Plugin):
|
|||
test = source.get("if", test)
|
||||
relative = source.get("relative", relative)
|
||||
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)
|
||||
relink = source.get("relink", relink)
|
||||
create = source.get("create", create)
|
||||
|
@ -87,6 +101,7 @@ class Link(Plugin):
|
|||
relative=relative,
|
||||
canonical_path=canonical_path,
|
||||
ignore_missing=ignore_missing,
|
||||
link_type=link_type,
|
||||
)
|
||||
else:
|
||||
if create:
|
||||
|
@ -104,7 +119,12 @@ class Link(Plugin):
|
|||
path, destination, relative=relative, canonical_path=canonical_path, force=force
|
||||
)
|
||||
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:
|
||||
self._log.info("All links have been set up")
|
||||
|
@ -230,7 +250,16 @@ class Link(Plugin):
|
|||
destination_dir = os.path.dirname(destination)
|
||||
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.
|
||||
|
||||
|
@ -249,11 +278,14 @@ class Link(Plugin):
|
|||
# destination directory
|
||||
elif not self._exists(link_name) and (ignore_missing or self._exists(absolute_source)):
|
||||
try:
|
||||
os.symlink(source, destination)
|
||||
if link_type == "symlink":
|
||||
os.symlink(source, destination)
|
||||
else: # link_type == "hardlink"
|
||||
os.link(absolute_source, destination)
|
||||
except OSError:
|
||||
self._log.warning(f"Linking failed {link_name} -> {source}")
|
||||
else:
|
||||
self._log.lowinfo(f"Creating link {link_name} -> {source}")
|
||||
self._log.lowinfo(f"Creating {link_type} {link_name} -> {source}")
|
||||
success = True
|
||||
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")
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import os
|
||||
import sys
|
||||
from typing import Callable, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
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:
|
||||
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