Initial Release:
Some checks failed
CI + Mark Stable / Test & Lint (push) Has been cancelled
CI + Mark Stable / Mark stable tag (push) Has been cancelled

https://chatgpt.com/share/6941a2a4-7974-800f-8911-9ab0bf1e3873
This commit is contained in:
2025-12-16 19:19:05 +01:00
parent 9d4eccd5e0
commit 8fd45cb87b
21 changed files with 767 additions and 28 deletions

1
tests/unit/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Intentionally empty: enables unittest discovery for this package.

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from dotlinker.backends.chezmoi import ChezmoiBackend
from dotlinker.backends.base import RunContext
from dotlinker.model import Mapping
class TestChezmoiBackend(unittest.TestCase):
def test_runs_add(self) -> None:
with tempfile.TemporaryDirectory() as td:
src = Path(td) / "zshrc"
src.write_text("x", encoding="utf-8")
m = Mapping(name="zshrc", backend="chezmoi", src=str(src), dest=None)
backend = ChezmoiBackend(exe="chezmoi")
with patch("subprocess.run") as run:
backend.pull(m, RunContext(dry_run=False, verbose=False))
run.assert_called_once()
args, kwargs = run.call_args
self.assertEqual(args[0][0:2], ["chezmoi", "add"])
self.assertIn(str(src.resolve()), args[0])
self.assertTrue(kwargs["check"])
def test_dry_run_does_not_execute(self) -> None:
with tempfile.TemporaryDirectory() as td:
src = Path(td) / "zshrc"
src.write_text("x", encoding="utf-8")
m = Mapping(name="zshrc", backend="chezmoi", src=str(src), dest=None)
backend = ChezmoiBackend(exe="chezmoi")
with patch("subprocess.run") as run:
backend.pull(m, RunContext(dry_run=True, verbose=False))
run.assert_not_called()

View File

@@ -0,0 +1,94 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from dotlinker.backends.cloud import CloudBackend
from dotlinker.backends.base import RunContext
from dotlinker.model import Mapping
class TestCloudBackend(unittest.TestCase):
def setUp(self) -> None:
os.environ["DOTLINKER_TIMESTAMP"] = "20251216T170000"
def tearDown(self) -> None:
os.environ.pop("DOTLINKER_TIMESTAMP", None)
def test_copies_file_then_links_and_backs_up_src(self) -> None:
with tempfile.TemporaryDirectory() as td:
root = Path(td)
src = root / "home" / ".gitconfig"
dest = root / "cloud" / ".gitconfig"
src.parent.mkdir(parents=True, exist_ok=True)
dest.parent.mkdir(parents=True, exist_ok=True)
src.write_text("A", encoding="utf-8")
m = Mapping(name="gitconfig", backend="cloud", src=str(src), dest=str(dest))
CloudBackend().pull(m, RunContext(dry_run=False, verbose=False))
self.assertTrue(dest.exists())
self.assertEqual(dest.read_text(encoding="utf-8"), "A")
self.assertTrue(src.is_symlink())
self.assertEqual(src.resolve(), dest.resolve())
bak = src.with_name(src.name + ".20251216T170000.bak")
self.assertTrue(bak.exists())
self.assertEqual(bak.read_text(encoding="utf-8"), "A")
def test_backs_up_existing_dest_file(self) -> None:
with tempfile.TemporaryDirectory() as td:
root = Path(td)
src = root / "home" / ".vimrc"
dest = root / "cloud" / ".vimrc"
src.parent.mkdir(parents=True, exist_ok=True)
dest.parent.mkdir(parents=True, exist_ok=True)
src.write_text("NEW", encoding="utf-8")
dest.write_text("OLD", encoding="utf-8")
m = Mapping(name="vimrc", backend="cloud", src=str(src), dest=str(dest))
CloudBackend().pull(m, RunContext(dry_run=False, verbose=False))
bak_dest = dest.with_name(dest.name + ".20251216T170000.bak")
self.assertTrue(bak_dest.exists())
self.assertEqual(bak_dest.read_text(encoding="utf-8"), "OLD")
self.assertEqual(dest.read_text(encoding="utf-8"), "NEW")
self.assertTrue(src.is_symlink())
def test_noop_when_already_linked(self) -> None:
with tempfile.TemporaryDirectory() as td:
root = Path(td)
src = root / "home" / ".zshrc"
dest = root / "cloud" / ".zshrc"
src.parent.mkdir(parents=True, exist_ok=True)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text("X", encoding="utf-8")
src.symlink_to(dest)
m = Mapping(name="zshrc", backend="cloud", src=str(src), dest=str(dest))
CloudBackend().pull(m, RunContext(dry_run=False, verbose=False))
self.assertTrue(src.is_symlink())
self.assertEqual(src.resolve(), dest.resolve())
self.assertEqual(dest.read_text(encoding="utf-8"), "X")
def test_requires_dest(self) -> None:
with tempfile.TemporaryDirectory() as td:
root = Path(td)
src = root / "home" / ".x"
src.parent.mkdir(parents=True, exist_ok=True)
src.write_text("x", encoding="utf-8")
m = Mapping(name="x", backend="cloud", src=str(src), dest=None)
with self.assertRaises(ValueError):
CloudBackend().pull(m, RunContext(dry_run=False, verbose=False))

64
tests/unit/test_cli.py Normal file
View File

@@ -0,0 +1,64 @@
from __future__ import annotations
import io
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch
from dotlinker.cli import main
class TestCli(unittest.TestCase):
def _run(self, argv: list[str]) -> tuple[int, str]:
buf = io.StringIO()
with patch("sys.argv", ["doli"] + argv), redirect_stdout(buf):
try:
main()
return 0, buf.getvalue()
except SystemExit as e:
return int(e.code or 0), buf.getvalue()
def test_no_subcommand_prints_help(self) -> None:
rc, out = self._run([])
self.assertEqual(rc, 0)
self.assertIn("usage:", out)
self.assertIn("{pull,add}", out)
def test_add_writes_config(self) -> None:
with tempfile.TemporaryDirectory() as td:
cfg = Path(td) / "config.yaml"
rc, _ = self._run(
["-c", str(cfg), "add", "-N", "zshrc", "-b", "chezmoi", "-s", "~/.zshrc"]
)
self.assertEqual(rc, 0)
self.assertTrue(cfg.exists())
text = cfg.read_text(encoding="utf-8")
self.assertIn("zshrc", text)
self.assertIn("chezmoi", text)
def test_pull_calls_backends(self) -> None:
with tempfile.TemporaryDirectory() as td:
cfg = Path(td) / "config.yaml"
# Create config with two mappings
cfg.write_text(
"mappings:\n"
" - name: zshrc\n"
" backend: chezmoi\n"
" src: ~/.zshrc\n"
" - name: nvim\n"
" backend: cloud\n"
" src: ~/.config/nvim\n"
" dest: ~/Nextcloud/dotfiles/.config/nvim\n",
encoding="utf-8",
)
with patch("dotlinker.cli.ChezmoiBackend.pull") as chez_pull, patch(
"dotlinker.cli.CloudBackend.pull"
) as cloud_pull:
rc, _ = self._run(["-c", str(cfg), "pull"])
self.assertEqual(rc, 0)
self.assertEqual(chez_pull.call_count, 1)
self.assertEqual(cloud_pull.call_count, 1)

57
tests/unit/test_config.py Normal file
View File

@@ -0,0 +1,57 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from dotlinker.config import default_config_path, load_config, save_config, upsert_mapping
from dotlinker.model import Mapping
class TestConfig(unittest.TestCase):
def test_default_config_path_respects_xdg(self) -> None:
with tempfile.TemporaryDirectory() as td:
xdg = Path(td) / "xdg"
os.environ["XDG_CONFIG_HOME"] = str(xdg)
p = default_config_path()
self.assertEqual(p, xdg / "dotlinker" / "config.yaml")
def test_load_config_missing_returns_empty(self) -> None:
with tempfile.TemporaryDirectory() as td:
cfg = Path(td) / "missing.yaml"
self.assertEqual(load_config(cfg), [])
def test_save_and_load_roundtrip(self) -> None:
with tempfile.TemporaryDirectory() as td:
cfg = Path(td) / "cfg.yaml"
mappings = [
Mapping(name="zshrc", backend="chezmoi", src="~/.zshrc", dest=None),
Mapping(
name="nvim",
backend="cloud",
src="~/.config/nvim",
dest="~/Nextcloud/dotfiles/.config/nvim",
),
]
save_config(cfg, mappings)
loaded = load_config(cfg)
self.assertEqual(loaded, mappings)
def test_upsert_adds_when_missing(self) -> None:
out = upsert_mapping([], Mapping("a", "chezmoi", "~/.a", None), replace=False)
self.assertEqual(len(out), 1)
self.assertEqual(out[0].name, "a")
def test_upsert_rejects_duplicate_without_replace(self) -> None:
items = [Mapping("a", "chezmoi", "~/.a", None)]
with self.assertRaises(ValueError):
upsert_mapping(items, Mapping("a", "cloud", "~/.a", "~/x"), replace=False)
def test_upsert_replaces_with_replace(self) -> None:
items = [Mapping("a", "chezmoi", "~/.a", None)]
out = upsert_mapping(items, Mapping("a", "cloud", "~/.a", "~/x"), replace=True)
self.assertEqual(out[0].backend, "cloud")
self.assertEqual(out[0].dest, "~/x")