From 8fd45cb87b9f0ad5c72b1b6d5d0736f803e72a20 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 16 Dec 2025 19:19:05 +0100 Subject: [PATCH] Initial Release: https://chatgpt.com/share/6941a2a4-7974-800f-8911-9ab0bf1e3873 --- .github/workflows/ci-and-mark-stable.yml | 71 +++++++++++++++ LICENSE | 22 ++++- MIRRORS | 4 + Makefile | 27 ++++++ README.md | 107 ++++++++++++++++++++++- flake.nix | 11 --- pyproject.toml | 25 +++--- src/dotlinker/__init__.py | 1 + src/dotlinker/backends/__init__.py | 1 + src/dotlinker/backends/base.py | 11 +++ src/dotlinker/backends/chezmoi.py | 13 +++ src/dotlinker/backends/cloud.py | 87 ++++++++++++++++++ src/dotlinker/cli.py | 66 ++++++++++++++ src/dotlinker/config.py | 28 ++++++ src/dotlinker/model.py | 11 +++ src/dotlinker/util.py | 54 ++++++++++++ tests/unit/__init__.py | 1 + tests/unit/test_backend_chezmoi.py | 40 +++++++++ tests/unit/test_backend_cloud.py | 94 ++++++++++++++++++++ tests/unit/test_cli.py | 64 ++++++++++++++ tests/unit/test_config.py | 57 ++++++++++++ 21 files changed, 767 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/ci-and-mark-stable.yml create mode 100644 MIRRORS create mode 100644 Makefile delete mode 100644 flake.nix create mode 100644 src/dotlinker/__init__.py create mode 100644 src/dotlinker/backends/__init__.py create mode 100644 src/dotlinker/backends/base.py create mode 100644 src/dotlinker/backends/chezmoi.py create mode 100644 src/dotlinker/backends/cloud.py create mode 100644 src/dotlinker/cli.py create mode 100644 src/dotlinker/config.py create mode 100644 src/dotlinker/model.py create mode 100644 src/dotlinker/util.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_backend_chezmoi.py create mode 100644 tests/unit/test_backend_cloud.py create mode 100644 tests/unit/test_cli.py create mode 100644 tests/unit/test_config.py diff --git a/.github/workflows/ci-and-mark-stable.yml b/.github/workflows/ci-and-mark-stable.yml new file mode 100644 index 0000000..90b9758 --- /dev/null +++ b/.github/workflows/ci-and-mark-stable.yml @@ -0,0 +1,71 @@ +name: CI + Mark Stable + +on: + push: + branches: + - main + +permissions: + contents: write # required to move the stable tag + +jobs: + ci: + name: Test & Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # required to access tags + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ruff + pip install . + + - name: Run tests + run: | + make test + + - name: Run ruff + run: | + ruff check src tests + + mark-stable: + name: Mark stable tag + runs-on: ubuntu-latest + needs: ci + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Find latest version tag + id: version + run: | + git fetch --tags + TAG=$(git tag --list 'v*' --sort=-version:refname | head -n 1) + if [ -z "$TAG" ]; then + echo "No version tag found, skipping stable tagging." + exit 0 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Move stable tag to latest version + if: steps.version.outputs.tag != '' + run: | + TAG="${{ steps.version.outputs.tag }}" + echo "Marking $TAG as stable" + + git tag -f stable "$TAG" + git push origin :refs/tags/stable || true + git push origin stable --force diff --git a/LICENSE b/LICENSE index c30e6e6..5d262c8 100644 --- a/LICENSE +++ b/LICENSE @@ -1 +1,21 @@ -All rights reserved by Kevin Veen-Birkenbach +MIT License + +Copyright (c) 2025 Kevin Veen-Birkenbach + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/MIRRORS b/MIRRORS new file mode 100644 index 0000000..2868eca --- /dev/null +++ b/MIRRORS @@ -0,0 +1,4 @@ +git@github.com:kevinveenbirkenbach/dotlinker.git +ssh://git@git.veen.world:2201/kevinveenbirkenbach/dotlinker.git +ssh://git@code.infinito.nexus:2201/kevinveenbirkenbach/dotlinker.git +https://pypi.org/project/doli/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1cc868f --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +SHELL := /usr/bin/env bash + +.PHONY: help test test-verbose clean + +PY ?= python +TEST_DIR ?= tests +TEST_PATTERN ?= "test_*.py" + +# Always test the current working tree, never site-packages +export PYTHONPATH := $(CURDIR)/src + +help: + @echo "Targets:" + @echo " make test - run unit tests (unittest, working tree)" + @echo " make test-verbose - run unit tests verbose (working tree)" + @echo " make clean - remove caches" + +test: + PYTHONPATH=$(PYTHONPATH) $(PY) -m unittest discover -s $(TEST_DIR) -p $(TEST_PATTERN) + +test-verbose: + PYTHONPATH=$(PYTHONPATH) $(PY) -m unittest discover -s $(TEST_DIR) -p $(TEST_PATTERN) -v + +clean: + rm -rf .pytest_cache .ruff_cache .mypy_cache + find . -type d -name "__pycache__" -print0 | xargs -0 -r rm -rf + find . -type f -name "*.pyc" -delete diff --git a/README.md b/README.md index 4c9152b..5b78196 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,107 @@ -# dotlinker +# dotlinker (doli) -Homepage: https://github.com/kevinveenbirkenbach/dotlinker +`dotlinker` (CLI: `doli`) helps you persist and synchronize configuration across multiple systems by +linking local config paths to either: + +- **chezmoi** (Git-based dotfiles management) +- **Nextcloud** (file-sync based persistence) + +It is designed to be **simple**, **deterministic**, and **safe by default** (with timestamped backups). + +## Features + +- Default config path: `~/.config/dotlinker/config.yaml` (XDG-aware via `XDG_CONFIG_HOME`) +- Backends: + - **chezmoi**: imports paths via `chezmoi add` + - **cloud**: copies data to a destination directory, creates `.bak` backups, and links back with symlinks +- Unit tests via `unittest` +- Makefile target: `make test` + +## Installation + +```bash +pip install . +``` + +Or editable install for development: + +```bash +pip install -e . +``` + +## Usage + +Show help: + +```bash +doli --help +doli +``` + +Add a mapping (non-interactive): + +```bash +doli add -N zshrc -b chezmoi -s ~/.zshrc +doli add -N nvim -b cloud -s ~/.config/nvim -d ~/Nextcloud/dotfiles/.config/nvim +``` + +Run the linking/import process: + +```bash +doli pull +``` + +Use a custom config path: + +```bash +doli -c ./my-config.yaml add -N nvim -b cloud -s ~/.config/nvim -d ~/Nextcloud/dotfiles/.config/nvim +doli -c ./my-config.yaml pull +``` + +## Configuration format + +`~/.config/dotlinker/config.yaml` + +```yaml +mappings: + - name: zshrc + backend: chezmoi + src: ~/.zshrc + + - name: nvim + backend: cloud + src: ~/.config/nvim + dest: ~/Nextcloud/dotfiles/.config/nvim +``` + +### Notes + +* For `backend: cloud`, `dest` is required. +* `cloud` creates timestamped backups: + + * destination backups: `dest..bak` + * source backups: `src..bak` +* If `src` is already a symlink pointing to `dest`, `doli pull` is a NOOP. + +## Development + +Run tests: + +```bash +make test +``` + +Run tests verbosely: + +```bash +make test-verbose +``` + +## License + +MIT License. See `LICENSE`. ## Author -Kevin Veen-Birkenbach + +Kevin Veen-Birkenbach +[https://www.veen.world/](https://www.veen.world/) diff --git a/flake.nix b/flake.nix deleted file mode 100644 index 17ad76b..0000000 --- a/flake.nix +++ /dev/null @@ -1,11 +0,0 @@ -{ - description = "dotlinker"; - inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - outputs = { self, nixpkgs }: - let system = "x86_64-linux"; pkgs = import nixpkgs { inherit system; }; - in { - devShells.${system}.default = pkgs.mkShell { - packages = with pkgs; [ python312 python312Packages.pytest python312Packages.ruff ]; - }; - }; -} diff --git a/pyproject.toml b/pyproject.toml index 91b4309..072ebcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,21 +1,20 @@ [build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" +requires = ["hatchling"] +build-backend = "hatchling.build" [project] -name = "dotlinker" -version = "0.1.0" -description = "" +name = "doli" +version = "0.0.0" +description = "Config linker for chezmoi and Nextcloud" readme = "README.md" requires-python = ">=3.10" +license = { text = "MIT" } authors = [{ name = "Kevin Veen-Birkenbach", email = "kevin@veen.world" }] -license = { text = "All rights reserved by Kevin Veen-Birkenbach" } -urls = { Homepage = "https://github.com/kevinveenbirkenbach/dotlinker" } +dependencies = ["PyYAML>=6.0"] -dependencies = [] +[project.urls] +Homepage = "https://www.veen.world/" +Repository = "https://github.com/kevinveenbirkenbach/dotlinker" -[tool.setuptools] -package-dir = {"" = "src"} - -[tool.setuptools.packages.find] -where = ["src"] +[project.scripts] +doli = "dotlinker.cli:main" diff --git a/src/dotlinker/__init__.py b/src/dotlinker/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/dotlinker/__init__.py @@ -0,0 +1 @@ + diff --git a/src/dotlinker/backends/__init__.py b/src/dotlinker/backends/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/dotlinker/backends/__init__.py @@ -0,0 +1 @@ + diff --git a/src/dotlinker/backends/base.py b/src/dotlinker/backends/base.py new file mode 100644 index 0000000..6fce95b --- /dev/null +++ b/src/dotlinker/backends/base.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from typing import Protocol +from ..model import Mapping + +@dataclass(frozen=True) +class RunContext: + dry_run: bool = False + verbose: bool = False + +class Backend(Protocol): + def pull(self, m: Mapping, ctx: RunContext) -> None: ... diff --git a/src/dotlinker/backends/chezmoi.py b/src/dotlinker/backends/chezmoi.py new file mode 100644 index 0000000..f61fd92 --- /dev/null +++ b/src/dotlinker/backends/chezmoi.py @@ -0,0 +1,13 @@ +import subprocess +from ..util import expand +from .base import RunContext +from ..model import Mapping + +class ChezmoiBackend: + def __init__(self, exe="chezmoi"): + self.exe = exe + + def pull(self, m: Mapping, ctx: RunContext): + if ctx.dry_run: + return + subprocess.run([self.exe, "add", str(expand(m.src))], check=True) diff --git a/src/dotlinker/backends/cloud.py b/src/dotlinker/backends/cloud.py new file mode 100644 index 0000000..772c58f --- /dev/null +++ b/src/dotlinker/backends/cloud.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +from ..model import Mapping +from ..util import ensure_parent, expand, is_same_symlink, timestamp +from .base import RunContext + + +class CloudBackend: + def pull(self, m: Mapping, ctx: RunContext) -> None: + if not m.dest: + raise ValueError("cloud requires dest") + + src = expand(m.src) + dest = expand(m.dest) + + # Safety: refuse destructive self-mapping + if src.absolute() == dest.absolute(): + raise ValueError(f"cloud mapping '{m.name}' has src == dest, refusing: {src}") + + # Already linked correctly -> noop + if is_same_symlink(src, dest): + if ctx.verbose: + print(f"[cloud] {m.name}: already linked") + return + + # If src is a symlink, but not the correct one -> backup and replace + if src.is_symlink(): + bak = src.with_name(src.name + "." + timestamp() + ".bak") + if ctx.verbose or ctx.dry_run: + print(f"[cloud] backup wrong symlink: {src} -> {bak}") + if not ctx.dry_run: + src.rename(bak) + + # Ensure destination parent exists + ensure_parent(dest) + + # If src doesn't exist (fresh system), create dest skeleton and link back + if not src.exists(): + if ctx.verbose: + print(f"[cloud] {m.name}: src missing, creating dest skeleton and linking") + if ctx.dry_run: + return + + if not dest.exists(): + # MVP behavior: create empty file by default + dest.touch(exist_ok=True) + + self._link(src, dest) + return + + # If destination already exists -> backup it + if dest.exists(): + dest_bak = dest.with_name(dest.name + "." + timestamp() + ".bak") + if ctx.verbose or ctx.dry_run: + print(f"[cloud] backup dest: {dest} -> {dest_bak}") + if not ctx.dry_run: + dest.rename(dest_bak) + + # Copy src -> dest + if ctx.verbose or ctx.dry_run: + print(f"[cloud] copy: {src} -> {dest}") + + if not ctx.dry_run: + if src.is_dir(): + # copytree requires dest not exist + shutil.copytree(src, dest, symlinks=True) + else: + shutil.copy2(src, dest) + + # Backup original src and replace with symlink + src_bak = src.with_name(src.name + "." + timestamp() + ".bak") + if ctx.verbose or ctx.dry_run: + print(f"[cloud] backup src: {src} -> {src_bak}") + + if ctx.dry_run: + return + + src.rename(src_bak) + self._link(src, dest) + + def _link(self, src: Path, dest: Path) -> None: + ensure_parent(src) + # Use absolute target for MVP (simple + robust) + src.symlink_to(dest) diff --git a/src/dotlinker/cli.py b/src/dotlinker/cli.py new file mode 100644 index 0000000..0fc377e --- /dev/null +++ b/src/dotlinker/cli.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from .config import default_config_path, load_config, save_config, upsert_mapping +from .model import Mapping +from .backends.chezmoi import ChezmoiBackend +from .backends.cloud import CloudBackend +from .backends.base import RunContext + + +def main() -> None: + p = argparse.ArgumentParser(prog="doli") + + p.add_argument( + "-c", + "--config", + help="Path to config file. Default: ~/.config/dotlinker/config.yaml (XDG-aware).", + default=None, + ) + + sub = p.add_subparsers(dest="cmd") # NOT required -> we can show help on missing cmd + + sub.add_parser("pull", help="Import local config into chezmoi/cloud and link back") + + a = sub.add_parser("add", help="Add a new mapping to the config") + a.add_argument("-N", "--name", help="Mapping name (unique)") + a.add_argument("-b", "--backend", choices=["chezmoi", "cloud"], help="Backend to use") + a.add_argument("-s", "--src", help="Source path (original location)") + a.add_argument("-d", "--dest", help="Destination path (required for cloud backend)") + a.add_argument("-r", "--replace", action="store_true", help="Replace existing mapping with same name") + + args = p.parse_args() + + # If no subcommand is provided: show help and exit successfully + if args.cmd is None: + p.print_help() + return + + cfg = Path(args.config).expanduser().resolve() if args.config else default_config_path() + + if args.cmd == "add": + if not args.name or not args.backend or not args.src: + raise SystemExit("add requires: --name, --backend, --src (or -N/-b/-s).") + + m = Mapping(args.name, args.backend, args.src, args.dest) + items = load_config(cfg) + items = upsert_mapping(items, m, replace=bool(args.replace)) + save_config(cfg, items) + return + + if args.cmd == "pull": + ctx = RunContext() + for m in load_config(cfg): + if m.backend == "chezmoi": + ChezmoiBackend().pull(m, ctx) + elif m.backend == "cloud": + CloudBackend().pull(m, ctx) + else: + raise SystemExit(f"Unknown backend: {m.backend}") + return + + +if __name__ == "__main__": + main() diff --git a/src/dotlinker/config.py b/src/dotlinker/config.py new file mode 100644 index 0000000..282c15c --- /dev/null +++ b/src/dotlinker/config.py @@ -0,0 +1,28 @@ +import os +from pathlib import Path +from dataclasses import asdict +import yaml +from .model import Mapping + +def default_config_path() -> Path: + xdg = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")) + return Path(xdg) / "dotlinker" / "config.yaml" + +def load_config(path: Path): + if not path.exists(): + return [] + data = yaml.safe_load(path.read_text()) or {} + return [Mapping(**m) for m in data.get("mappings", [])] + +def save_config(path: Path, mappings): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump({"mappings": [asdict(m) for m in mappings]}, sort_keys=False)) + +def upsert_mapping(mappings, new, replace=False): + for i, m in enumerate(mappings): + if m.name == new.name: + if not replace: + raise ValueError("Mapping already exists") + mappings[i] = new + return mappings + return mappings + [new] diff --git a/src/dotlinker/model.py b/src/dotlinker/model.py new file mode 100644 index 0000000..c3e46f5 --- /dev/null +++ b/src/dotlinker/model.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from typing import Literal, Optional + +BackendName = Literal["chezmoi", "cloud"] + +@dataclass(frozen=True) +class Mapping: + name: str + backend: BackendName + src: str + dest: Optional[str] = None diff --git a/src/dotlinker/util.py b/src/dotlinker/util.py new file mode 100644 index 0000000..c5b889b --- /dev/null +++ b/src/dotlinker/util.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import os +from datetime import datetime +from pathlib import Path + + +def expand(path: str) -> Path: + """ + Expand user (~) and return an absolute path WITHOUT resolving symlinks. + + This is critical because resolving would destroy symlink information, + which we need to detect "already linked" cases safely. + """ + p = Path(os.path.expanduser(path)) + + if not p.is_absolute(): + p = Path.cwd() / p + + # .absolute() does NOT resolve symlinks (unlike .resolve()). + return p.absolute() + + +def timestamp() -> str: + fixed = os.environ.get("DOTLINKER_TIMESTAMP") + if fixed: + return fixed + return datetime.now().strftime("%Y%m%dT%H%M%S") + + +def ensure_parent(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def is_same_symlink(src: Path, dest: Path) -> bool: + """ + Return True if src is a symlink pointing to dest. + + Works even if dest doesn't exist (no resolve()). + """ + if not src.is_symlink(): + return False + + try: + link_target = Path(os.readlink(src)) + except OSError: + return False + + # Handle relative symlink targets + if not link_target.is_absolute(): + link_target = src.parent / link_target + + # Compare as absolute paths without following symlinks + return link_target.absolute() == dest.absolute() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..1844000 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +# Intentionally empty: enables unittest discovery for this package. diff --git a/tests/unit/test_backend_chezmoi.py b/tests/unit/test_backend_chezmoi.py new file mode 100644 index 0000000..22ecf26 --- /dev/null +++ b/tests/unit/test_backend_chezmoi.py @@ -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() diff --git a/tests/unit/test_backend_cloud.py b/tests/unit/test_backend_cloud.py new file mode 100644 index 0000000..adda858 --- /dev/null +++ b/tests/unit/test_backend_cloud.py @@ -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)) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..b8ed348 --- /dev/null +++ b/tests/unit/test_cli.py @@ -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) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..fe6a0b9 --- /dev/null +++ b/tests/unit/test_config.py @@ -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")