Skip to content

Commit

Permalink
Support creating hardlinks
Browse files Browse the repository at this point in the history
Closes #334
  • Loading branch information
kurtmckee committed Jan 14, 2025
1 parent b8891c5 commit 73b1b75
Show file tree
Hide file tree
Showing 3 changed files with 130 additions and 8 deletions.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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) |
Expand Down
40 changes: 36 additions & 4 deletions src/dotbot/plugins/link.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,19 @@ def handle(self, directive: str, data: Any) -> bool:
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)
Expand All @@ -45,6 +53,12 @@ def _process_links(self, links: Any) -> bool:
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)
Expand Down Expand Up @@ -87,6 +101,7 @@ def _process_links(self, links: Any) -> bool:
relative=relative,
canonical_path=canonical_path,
ignore_missing=ignore_missing,
link_type=link_type,
)
else:
if create:
Expand All @@ -104,7 +119,12 @@ def _process_links(self, links: Any) -> bool:
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")
Expand Down Expand Up @@ -230,7 +250,16 @@ def _relative_path(self, source: str, destination: str) -> str:
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.
Expand All @@ -249,11 +278,14 @@ def _link(self, source: str, link_name: str, *, relative: bool, canonical_path:
# 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")
Expand Down
90 changes: 89 additions & 1 deletion tests/test_link.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os
import sys
from typing import Callable, Optional
from typing import Any, Callable, Dict, List, Optional

import pytest

Expand Down Expand Up @@ -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

0 comments on commit 73b1b75

Please sign in to comment.