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
|
## 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]
|
[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"]
|
|
||||||
|
|||||||
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