Initial Release:
https://chatgpt.com/share/6941a2a4-7974-800f-8911-9ab0bf1e3873
This commit is contained in:
71
.github/workflows/ci-and-mark-stable.yml
vendored
Normal file
71
.github/workflows/ci-and-mark-stable.yml
vendored
Normal 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
22
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.
|
||||
4
MIRRORS
Normal file
4
MIRRORS
Normal 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
27
Makefile
Normal 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
107
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.<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
|
||||
Kevin Veen-Birkenbach <kevin@veen.world>
|
||||
|
||||
Kevin Veen-Birkenbach
|
||||
[https://www.veen.world/](https://www.veen.world/)
|
||||
|
||||
11
flake.nix
11
flake.nix
@@ -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 ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
1
src/dotlinker/__init__.py
Normal file
1
src/dotlinker/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
src/dotlinker/backends/__init__.py
Normal file
1
src/dotlinker/backends/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
11
src/dotlinker/backends/base.py
Normal file
11
src/dotlinker/backends/base.py
Normal 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: ...
|
||||
13
src/dotlinker/backends/chezmoi.py
Normal file
13
src/dotlinker/backends/chezmoi.py
Normal 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)
|
||||
87
src/dotlinker/backends/cloud.py
Normal file
87
src/dotlinker/backends/cloud.py
Normal 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
66
src/dotlinker/cli.py
Normal 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
28
src/dotlinker/config.py
Normal 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
11
src/dotlinker/model.py
Normal 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
54
src/dotlinker/util.py
Normal 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
1
tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Intentionally empty: enables unittest discovery for this package.
|
||||
40
tests/unit/test_backend_chezmoi.py
Normal file
40
tests/unit/test_backend_chezmoi.py
Normal 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()
|
||||
94
tests/unit/test_backend_cloud.py
Normal file
94
tests/unit/test_backend_cloud.py
Normal 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
64
tests/unit/test_cli.py
Normal 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
57
tests/unit/test_config.py
Normal 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")
|
||||
Reference in New Issue
Block a user