1
0
Fork 0
mirror of synced 2025-01-23 12:10:28 -05:00

Support creating hardlinks

Closes #334
This commit is contained in:
Kurt McKee 2025-01-14 07:36:13 -06:00
parent b8891c5fb7
commit 73b1b758d5
No known key found for this signature in database
GPG key ID: 64713C0B5BA8E1C2
3 changed files with 130 additions and 8 deletions

View file

@ -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) |

View file

@ -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")

View file

@ -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