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

View File

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

22
LICENSE
View File

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

4
MIRRORS Normal file
View File

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

27
Makefile Normal file
View File

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

107
README.md
View File

@@ -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.<timestamp>.bak`
* source backups: `src.<timestamp>.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 ## Author
Kevin Veen-Birkenbach <kevin@veen.world>
Kevin Veen-Birkenbach
[https://www.veen.world/](https://www.veen.world/)

View File

@@ -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 ];
};
};
}

View File

@@ -1,21 +1,20 @@
[build-system] [build-system]
requires = ["setuptools>=68", "wheel"] requires = ["hatchling"]
build-backend = "setuptools.build_meta" build-backend = "hatchling.build"
[project] [project]
name = "dotlinker" name = "doli"
version = "0.1.0" version = "0.0.0"
description = "" description = "Config linker for chezmoi and Nextcloud"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
license = { text = "MIT" }
authors = [{ name = "Kevin Veen-Birkenbach", email = "kevin@veen.world" }] authors = [{ name = "Kevin Veen-Birkenbach", email = "kevin@veen.world" }]
license = { text = "All rights reserved by Kevin Veen-Birkenbach" } dependencies = ["PyYAML>=6.0"]
urls = { Homepage = "https://github.com/kevinveenbirkenbach/dotlinker" }
dependencies = [] [project.urls]
Homepage = "https://www.veen.world/"
Repository = "https://github.com/kevinveenbirkenbach/dotlinker"
[tool.setuptools] [project.scripts]
package-dir = {"" = "src"} doli = "dotlinker.cli:main"
[tool.setuptools.packages.find]
where = ["src"]

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

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

View File

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

View File

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

66
src/dotlinker/cli.py Normal file
View File

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

28
src/dotlinker/config.py Normal file
View File

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

11
src/dotlinker/model.py Normal file
View File

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

54
src/dotlinker/util.py Normal file
View File

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

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