diff --git a/CHANGES b/CHANGES index df38b5bf..562a2814 100644 --- a/CHANGES +++ b/CHANGES @@ -37,6 +37,20 @@ $ uvx --from 'vcspull' --prerelease allow vcspull _Notes on upcoming releases will be added here_ +### Bug fixes + +#### `config`: Preserve symlinks when writing config files (#538) + +Config files that are symbolic links (e.g. `~/.vcspull.yaml` pointing to a +dotfiles directory) were being silently replaced by regular files on every +write, destroying the symlink. The write now goes through the link — a temp +file is created next to the real target, renamed into place, and the symlink +directory entry is preserved. + +Format detection now also inspects the symlink target's extension, so a +`.yaml` symlink pointing to a `.json` file serialises correctly as JSON rather +than overwriting the target with YAML. + ## vcspull v1.58.0 (2026-03-01) ### New features diff --git a/src/vcspull/_internal/config_reader.py b/src/vcspull/_internal/config_reader.py index 22fc602e..9ff6f412 100644 --- a/src/vcspull/_internal/config_reader.py +++ b/src/vcspull/_internal/config_reader.py @@ -8,6 +8,56 @@ import yaml FormatLiteral = t.Literal["json", "yaml"] +_SUPPORTED_CONFIG_SUFFIXES: dict[str, FormatLiteral] = { + ".json": "json", + ".yaml": "yaml", + ".yml": "yaml", +} + + +def config_format_from_path(path: pathlib.Path) -> FormatLiteral | None: + """Return config format inferred from a path or symlink target. + + The visible path remains the config identity, but symlinks may point + at a differently named target. When the target advertises a supported + config suffix, prefer that format; otherwise fall back to the visible + path suffix. + + Parameters + ---------- + path : pathlib.Path + Path to inspect. + + Returns + ------- + FormatLiteral | None + ``"json"`` or ``"yaml"`` when a supported suffix is found, + otherwise ``None``. + + Examples + -------- + >>> config_format_from_path(pathlib.Path("config.yaml")) + 'yaml' + >>> import tempfile + >>> with tempfile.TemporaryDirectory() as tmp: + ... root = pathlib.Path(tmp) + ... target = root / "config.json" + ... _ = target.write_text("{}", encoding="utf-8") + ... link = root / ".vcspull.yaml" + ... link.symlink_to(target) + ... config_format_from_path(link) + 'json' + """ + path_format = _SUPPORTED_CONFIG_SUFFIXES.get(path.suffix.lower()) + + target_format: FormatLiteral | None = None + if path.is_symlink(): + resolved = path.resolve(strict=False) + target_format = _SUPPORTED_CONFIG_SUFFIXES.get(resolved.suffix.lower()) + + return target_format or path_format + + RawConfigData: t.TypeAlias = dict[t.Any, t.Any] @@ -114,14 +164,12 @@ def _from_file(cls, path: pathlib.Path) -> dict[str, t.Any]: # the formatter helpers directly; # 3) Keep this basic loader but add an opt-in path for duplicate-aware # parsing so commands like ``vcspull add`` can avoid data loss. - # Revisit once the new ``vcspull add`` flow lands so both commands share - # the same duplication safeguards. + # ``vcspull add`` now uses ``DuplicateAwareConfigReader`` for reading + # (see ``cli/add.py``). This basic loader remains for simpler read + # contexts. Option 1 (shared utility) is the cleanest long-term path. - if path.suffix in {".yaml", ".yml"}: - fmt: FormatLiteral = "yaml" - elif path.suffix == ".json": - fmt = "json" - else: + fmt = config_format_from_path(path) + if fmt is None: msg = f"{path.suffix} not supported in {path}" raise NotImplementedError(msg) @@ -335,7 +383,7 @@ def _load_from_path( cls, path: pathlib.Path, ) -> tuple[dict[str, t.Any], dict[str, list[t.Any]], list[tuple[str, t.Any]]]: - if path.suffix.lower() in {".yaml", ".yml"}: + if config_format_from_path(path) == "yaml": content = path.read_text(encoding="utf-8") return cls._load_yaml_with_duplicates(content) diff --git a/src/vcspull/cli/add.py b/src/vcspull/cli/add.py index b12b2164..a7b24aab 100644 --- a/src/vcspull/cli/add.py +++ b/src/vcspull/cli/add.py @@ -13,7 +13,10 @@ from colorama import Fore, Style -from vcspull._internal.config_reader import DuplicateAwareConfigReader +from vcspull._internal.config_reader import ( + DuplicateAwareConfigReader, + config_format_from_path, +) from vcspull._internal.private_path import PrivatePath from vcspull.config import ( canonicalize_workspace_path, @@ -22,6 +25,7 @@ get_pin_reason, is_pinned_for_op, merge_duplicate_workspace_roots, + normalize_config_file_path, save_config, save_config_json, save_config_yaml_with_items, @@ -340,7 +344,7 @@ def _save_ordered_items( >>> "~/code/" in data True """ - if config_file_path.suffix.lower() == ".json": + if config_format_from_path(config_file_path) == "json": save_config_json( config_file_path, _collapse_ordered_items_to_dict(ordered_items), @@ -508,7 +512,9 @@ def add_repo( # Determine config file config_file_path: pathlib.Path if config_file_path_str: - config_file_path = pathlib.Path(config_file_path_str).expanduser().resolve() + config_file_path = normalize_config_file_path( + pathlib.Path(config_file_path_str) + ) else: home_configs = find_home_config_files(filetype=["yaml"]) if not home_configs: diff --git a/src/vcspull/cli/discover.py b/src/vcspull/cli/discover.py index 9e64a183..af9041ec 100644 --- a/src/vcspull/cli/discover.py +++ b/src/vcspull/cli/discover.py @@ -22,6 +22,7 @@ get_pin_reason, is_pinned_for_op, merge_duplicate_workspace_roots, + normalize_config_file_path, normalize_workspace_roots, save_config, workspace_root_label, @@ -327,7 +328,9 @@ def discover_repos( config_file_path: pathlib.Path if config_file_path_str: - config_file_path = pathlib.Path(config_file_path_str).expanduser().resolve() + config_file_path = normalize_config_file_path( + pathlib.Path(config_file_path_str) + ) else: home_configs = find_home_config_files(filetype=["yaml"]) if not home_configs: diff --git a/src/vcspull/cli/fmt.py b/src/vcspull/cli/fmt.py index a4c0a18c..858c32a7 100644 --- a/src/vcspull/cli/fmt.py +++ b/src/vcspull/cli/fmt.py @@ -19,6 +19,7 @@ find_home_config_files, is_pinned_for_op, merge_duplicate_workspace_roots, + normalize_config_file_path, normalize_workspace_roots, save_config, ) @@ -570,7 +571,9 @@ def format_config_file( else: # Format single config file if config_file_path_str: - config_file_path = pathlib.Path(config_file_path_str).expanduser().resolve() + config_file_path = normalize_config_file_path( + pathlib.Path(config_file_path_str) + ) else: home_configs = find_home_config_files(filetype=["yaml"]) if not home_configs: diff --git a/src/vcspull/cli/import_cmd/_common.py b/src/vcspull/cli/import_cmd/_common.py index 3580c29c..b1d050ab 100644 --- a/src/vcspull/cli/import_cmd/_common.py +++ b/src/vcspull/cli/import_cmd/_common.py @@ -14,7 +14,10 @@ import sys import typing as t -from vcspull._internal.config_reader import DuplicateAwareConfigReader +from vcspull._internal.config_reader import ( + DuplicateAwareConfigReader, + config_format_from_path, +) from vcspull._internal.private_path import PrivatePath from vcspull._internal.remotes import ( AuthenticationError, @@ -33,6 +36,7 @@ get_pin_reason, is_pinned_for_op, merge_duplicate_workspace_roots, + normalize_config_file_path, save_config, workspace_root_label, ) @@ -560,11 +564,11 @@ def _resolve_config_file(config_path_str: str | None) -> pathlib.Path: Returns ------- pathlib.Path - Resolved config file path + Absolute config file path """ if config_path_str: - path = pathlib.Path(config_path_str).expanduser().resolve() - if path.suffix.lower() not in {".yaml", ".yml", ".json"}: + path = normalize_config_file_path(pathlib.Path(config_path_str)) + if config_format_from_path(path) is None: msg = f"Unsupported config file type: {path.suffix}" raise ValueError(msg) return path diff --git a/src/vcspull/config.py b/src/vcspull/config.py index 85c46ff6..2d3ffc9a 100644 --- a/src/vcspull/config.py +++ b/src/vcspull/config.py @@ -18,7 +18,11 @@ from vcspull.validator import is_valid_config from . import exc -from ._internal.config_reader import ConfigReader, DuplicateAwareConfigReader +from ._internal.config_reader import ( + ConfigReader, + DuplicateAwareConfigReader, + config_format_from_path, +) from .types import ConfigDict, RawConfigDict, WorktreeConfigDict from .util import get_config_dir, update_dict @@ -54,6 +58,48 @@ def expand_dir( return dir_ +def normalize_config_file_path( + path: pathlib.Path, + cwd: pathlib.Path | Callable[[], pathlib.Path] = pathlib.Path.cwd, +) -> pathlib.Path: + """Return absolute config file path without resolving symlinks. + + Symlink entry names are preserved intact so that downstream operations + (e.g. atomic writes) can resolve them as needed, while the logical path + is used for display and identity. + + Parameters + ---------- + path : pathlib.Path + Config file path to normalize. + cwd : pathlib.Path, optional + Current working dir (used to resolve relative paths). Defaults to + :py:meth:`pathlib.Path.cwd`. + + Returns + ------- + pathlib.Path + Absolute config file path with symlink names preserved. + + Examples + -------- + >>> normalize_config_file_path(pathlib.Path("~/cfg.yaml")).name + 'cfg.yaml' + >>> normalize_config_file_path( + ... pathlib.Path("configs/vcspull.yaml"), + ... cwd=pathlib.Path("/tmp/project"), + ... ) # doctest: +ELLIPSIS + PosixPath('.../configs/vcspull.yaml') + """ + path = pathlib.Path(os.path.expandvars(str(path))).expanduser() + if callable(cwd): + cwd = cwd() + + if not path.is_absolute(): + path = pathlib.Path(os.path.normpath(cwd / path)) + return path + + def _validate_worktrees_config( worktrees_raw: t.Any, repo_name: str, @@ -349,7 +395,27 @@ def is_valid_config_dict(val: t.Any) -> t.TypeGuard[ConfigDict]: def find_home_config_files( filetype: list[str] | None = None, ) -> list[pathlib.Path]: - """Return configs of ``.vcspull.{yaml,json}`` in user's home directory.""" + """Return configs of ``.vcspull.{yaml,json}`` in user's home directory. + + The returned path preserves the logical home entry name so callers + keep the config type implied by ``.yaml`` or ``.json`` even when the + file is a symlink. + + Parameters + ---------- + filetype : list of str, optional + File types to search for (default ``["json", "yaml"]``) + + Returns + ------- + list of pathlib.Path + Absolute paths to discovered config files + + Examples + -------- + >>> find_home_config_files() + [] + """ if filetype is None: filetype = ["json", "yaml"] configs: list[pathlib.Path] = [] @@ -357,9 +423,9 @@ def find_home_config_files( check_yaml = "yaml" in filetype check_json = "json" in filetype - yaml_config = pathlib.Path("~/.vcspull.yaml").expanduser() + yaml_config = normalize_config_file_path(pathlib.Path("~/.vcspull.yaml")) has_yaml_config = check_yaml and yaml_config.exists() - json_config = pathlib.Path("~/.vcspull.json").expanduser() + json_config = normalize_config_file_path(pathlib.Path("~/.vcspull.json")) has_json_config = check_json and json_config.exists() if not has_yaml_config and not has_json_config: @@ -668,20 +734,48 @@ def is_config_file( def _atomic_write(target: pathlib.Path, content: str) -> None: """Write content to a file atomically via temp-file-then-rename. + If *target* is a symbolic link the write goes through the symlink: + the temporary file is created next to the resolved destination and + the rename replaces the resolved path, leaving the symlink intact. + Parameters ---------- target : pathlib.Path - Destination file path + Destination file path (may be a symlink) content : str Content to write + + Examples + -------- + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as tmp: + ... p = pathlib.Path(tmp) / "test.txt" + ... _atomic_write(p, "hello") + ... p.read_text(encoding="utf-8") + 'hello' + + Symlinks are preserved — the real target is updated: + + >>> with tempfile.TemporaryDirectory() as tmp: + ... real = pathlib.Path(tmp) / "real.txt" + ... _ = real.write_text("old", encoding="utf-8") + ... link = pathlib.Path(tmp) / "link.txt" + ... link.symlink_to(real) + ... _atomic_write(link, "new") + ... link.is_symlink(), link.read_text(encoding="utf-8") + (True, 'new') """ + # Resolve symlinks so the temp file lives next to the real + # destination and the rename replaces the real file, not the symlink. + resolved = target.resolve() + original_mode: int | None = None - if target.exists(): - original_mode = target.stat().st_mode + if resolved.exists(): + original_mode = resolved.stat().st_mode fd, tmp_path = tempfile.mkstemp( - dir=target.parent, - prefix=f".{target.name}.", + dir=resolved.parent, + prefix=f".{resolved.name}.", suffix=".tmp", ) try: @@ -689,7 +783,7 @@ def _atomic_write(target: pathlib.Path, content: str) -> None: f.write(content) if original_mode is not None: pathlib.Path(tmp_path).chmod(original_mode) - pathlib.Path(tmp_path).replace(target) + pathlib.Path(tmp_path).replace(resolved) except BaseException: # Clean up the temp file on any failure with contextlib.suppress(OSError): @@ -706,6 +800,15 @@ def save_config_yaml(config_file_path: pathlib.Path, data: dict[t.Any, t.Any]) - Path to the configuration file to write data : dict Configuration data to save + + Examples + -------- + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as tmp: + ... p = pathlib.Path(tmp) / "cfg.yaml" + ... save_config_yaml(p, {"~/code/": {"myrepo": "git+https://example.com/repo.git"}}) + ... "myrepo" in p.read_text(encoding="utf-8") + True """ yaml_content = ConfigReader._dump( fmt="yaml", @@ -724,6 +827,16 @@ def save_config_json(config_file_path: pathlib.Path, data: dict[t.Any, t.Any]) - Path to the configuration file to write data : dict Configuration data to save + + Examples + -------- + >>> import json, pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as tmp: + ... p = pathlib.Path(tmp) / "cfg.json" + ... save_config_json(p, {"~/code/": {"myrepo": "git+https://example.com/repo.git"}}) + ... loaded = json.loads(p.read_text(encoding="utf-8")) + ... "~/code/" in loaded + True """ json_content = ConfigReader._dump( fmt="json", @@ -749,17 +862,17 @@ def save_config(config_file_path: pathlib.Path, data: dict[t.Any, t.Any]) -> Non >>> with tempfile.TemporaryDirectory() as tmp: ... p = pathlib.Path(tmp) / "test.json" ... save_config(p, {"~/code/": {"repo": {"repo": "git+https://x"}}}) - ... loaded = json.loads(p.read_text()) + ... loaded = json.loads(p.read_text(encoding="utf-8")) ... loaded["~/code/"]["repo"]["repo"] 'git+https://x' >>> with tempfile.TemporaryDirectory() as tmp: ... p = pathlib.Path(tmp) / "test.yaml" ... save_config(p, {"~/code/": {"repo": {"repo": "git+https://x"}}}) - ... "repo" in p.read_text() + ... "repo" in p.read_text(encoding="utf-8") True """ - if config_file_path.suffix.lower() == ".json": + if config_format_from_path(config_file_path) == "json": save_config_json(config_file_path, data) else: save_config_yaml(config_file_path, data) @@ -769,7 +882,34 @@ def save_config_yaml_with_items( config_file_path: pathlib.Path, items: list[tuple[str, t.Any]], ) -> None: - """Persist configuration data while preserving duplicate top-level sections.""" + """Persist configuration data while preserving duplicate top-level sections. + + Unlike :func:`save_config_yaml`, which loses duplicate keys when given a + plain ``dict``, this function accepts ordered ``(label, data)`` pairs so + that two workspace-root entries with the same key are each serialised as a + separate YAML block. + + Parameters + ---------- + config_file_path : pathlib.Path + Destination config file (may be a symlink; the real target is updated). + items : list of tuple[str, Any] + Ordered ``(section_label, section_data)`` pairs. Each pair becomes one + YAML document block in the output. + + Examples + -------- + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as tmp: + ... p = pathlib.Path(tmp) / "cfg.yaml" + ... save_config_yaml_with_items(p, [ + ... ("~/code/", {"flask": "git+https://github.com/pallets/flask.git"}), + ... ("~/code/", {"django": "git+https://github.com/django/django.git"}), + ... ]) + ... content = p.read_text(encoding="utf-8") + ... "flask" in content and "django" in content + True + """ documents: list[str] = [] for label, section in items: diff --git a/tests/cli/test_add.py b/tests/cli/test_add.py index 1cfee8ac..78d261c6 100644 --- a/tests/cli/test_add.py +++ b/tests/cli/test_add.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import json import logging import os import pathlib @@ -330,6 +331,81 @@ def test_add_repo_creates_new_file( assert "newrepo" in config["~/"] +def test_add_repo_uses_home_symlink_config_without_losing_yaml_suffix( + tmp_path: pathlib.Path, + monkeypatch: MonkeyPatch, +) -> None: + """Default home config symlinks should still load and save as YAML.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + dotfiles_dir = tmp_path / "dotfiles" + dotfiles_dir.mkdir() + + real_config = dotfiles_dir / "vcspull-config" + real_config.write_text("~/code/: {}\n", encoding="utf-8") + + symlink = tmp_path / ".vcspull.yaml" + symlink.symlink_to(real_config) + + repo_path = tmp_path / "code" / "newrepo" + repo_path.mkdir(parents=True) + + add_repo( + name="newrepo", + url="git+https://github.com/user/newrepo.git", + config_file_path_str=None, + path=str(repo_path), + workspace_root_path="~/code/", + dry_run=False, + ) + + assert symlink.is_symlink() + assert symlink.resolve() == real_config.resolve() + + config_text = real_config.read_text(encoding="utf-8") + assert "newrepo" in config_text + assert "git+https://github.com/user/newrepo.git" in config_text + + +def test_add_repo_uses_target_json_format_for_home_symlink( + tmp_path: pathlib.Path, + monkeypatch: MonkeyPatch, +) -> None: + """Default home symlinks should save using the supported target format.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + dotfiles_dir = tmp_path / "dotfiles" + dotfiles_dir.mkdir() + + real_config = dotfiles_dir / "vcspull.json" + real_config.write_text("{}\n", encoding="utf-8") + + symlink = tmp_path / ".vcspull.yaml" + symlink.symlink_to(real_config) + + repo_path = tmp_path / "code" / "jsonrepo" + repo_path.mkdir(parents=True) + + add_repo( + name="jsonrepo", + url="git+https://github.com/user/jsonrepo.git", + config_file_path_str=None, + path=str(repo_path), + workspace_root_path="~/code/", + dry_run=False, + ) + + assert symlink.is_symlink() + assert symlink.resolve() == real_config.resolve() + + data = json.loads(real_config.read_text(encoding="utf-8")) + assert data["~/code/"]["jsonrepo"]["repo"] == ( + "git+https://github.com/user/jsonrepo.git" + ) + + def test_add_repo_invalid_config_logs_private_path( user_path: pathlib.Path, caplog: pytest.LogCaptureFixture, diff --git a/tests/cli/test_import_repos.py b/tests/cli/test_import_repos.py index aeb432ca..3aa6e6d4 100644 --- a/tests/cli/test_import_repos.py +++ b/tests/cli/test_import_repos.py @@ -188,6 +188,26 @@ def test_resolve_config_file( assert result.name == expected_suffix +def test_resolve_config_file_accepts_extensionless_symlink_alias( + tmp_path: pathlib.Path, + monkeypatch: MonkeyPatch, +) -> None: + """Explicit alias paths should inherit a supported target config format.""" + monkeypatch.setenv("HOME", str(tmp_path)) + + real_config = tmp_path / ".vcspull.yaml" + real_config.write_text("~/repos/: {}\n", encoding="utf-8") + + alias_path = tmp_path / "vcspull-config" + alias_path.symlink_to(real_config) + + result = _resolve_config_file(str(alias_path)) + + assert result == alias_path + assert result.suffix == "" + assert result.resolve() == real_config.resolve() + + class ImportReposFixture(t.NamedTuple): """Fixture for _run_import test cases.""" diff --git a/tests/test_config_file.py b/tests/test_config_file.py index 6a30b1fa..b51fbc80 100644 --- a/tests/test_config_file.py +++ b/tests/test_config_file.py @@ -9,7 +9,7 @@ import pytest from vcspull import config, exc -from vcspull._internal.config_reader import ConfigReader +from vcspull._internal.config_reader import ConfigReader, config_format_from_path from vcspull.config import expand_dir, extract_repos from vcspull.validator import is_valid_config @@ -244,6 +244,42 @@ def test_find_home_config_files_both_types_still_raises( config.find_home_config_files() +def test_find_home_config_files_preserves_symlink_suffix( + tmp_path: pathlib.Path, +) -> None: + """Symlinked home configs should keep the logical suffix for format detection.""" + dotfiles_dir = tmp_path / ".dot-config" + dotfiles_dir.mkdir() + real_file = dotfiles_dir / "vcspull-config" + real_file.write_text("~/code/: {}\n", encoding="utf-8") + + symlink = tmp_path / ".vcspull.yaml" + symlink.symlink_to(real_file) + + with EnvironmentVarGuard() as env: + env.set("HOME", str(tmp_path)) + results = config.find_home_config_files() + + assert len(results) == 1 + assert results[0] == symlink + assert results[0].suffix == ".yaml" + assert results[0].is_symlink() + assert results[0].resolve() == real_file.resolve() + + +def test_config_format_from_path_prefers_supported_symlink_target( + tmp_path: pathlib.Path, +) -> None: + """Symlink targets with supported suffixes should determine the config format.""" + real_json = tmp_path / "vcspull.json" + real_json.write_text("{}\n", encoding="utf-8") + + mismatch = tmp_path / ".vcspull.yaml" + mismatch.symlink_to(real_json) + + assert config_format_from_path(mismatch) == "json" + + def test_in_dir( config_path: pathlib.Path, yaml_config: pathlib.Path, diff --git a/tests/test_config_writer.py b/tests/test_config_writer.py index fbc7c7fb..b596b1d4 100644 --- a/tests/test_config_writer.py +++ b/tests/test_config_writer.py @@ -8,6 +8,8 @@ import pytest from vcspull.config import ( + _atomic_write, + save_config, save_config_json, save_config_yaml, save_config_yaml_with_items, @@ -171,3 +173,69 @@ def test_save_config_json_atomic_preserves_permissions( save_config_json(config_path, data) assert config_path.stat().st_mode & 0o777 == 0o644 + + +def test_atomic_write_through_symlink_preserves_symlink( + tmp_path: pathlib.Path, +) -> None: + """Writing through a symlink should update the target and keep the link.""" + target_dir = tmp_path / "dotfiles" + target_dir.mkdir() + real_file = target_dir / ".vcspull.yaml" + real_file.write_text("~/old/: {}\n", encoding="utf-8") + + link_path = tmp_path / ".vcspull.yaml" + link_path.symlink_to(real_file) + + _atomic_write(link_path, "~/new/:\n repo: {}\n") + + # Symlink must still be a symlink pointing to the original target + assert link_path.is_symlink() + assert link_path.resolve() == real_file.resolve() + + # Real file has the new content + assert real_file.read_text(encoding="utf-8") == "~/new/:\n repo: {}\n" + + # No temp files left in either directory + for d in (tmp_path, target_dir): + leftovers = [f for f in d.iterdir() if ".tmp" in f.name] + assert leftovers == [] + + +def test_atomic_write_through_symlink_preserves_permissions( + tmp_path: pathlib.Path, +) -> None: + """File permissions of the real target should survive a symlink write.""" + target_dir = tmp_path / "dotfiles" + target_dir.mkdir() + real_file = target_dir / ".vcspull.yaml" + real_file.write_text("~/old/: {}\n", encoding="utf-8") + real_file.chmod(0o600) + + link_path = tmp_path / ".vcspull.yaml" + link_path.symlink_to(real_file) + + _atomic_write(link_path, "~/new/:\n repo: {}\n") + + assert real_file.stat().st_mode & 0o777 == 0o600 + assert link_path.is_symlink() + + +def test_save_config_via_symlink_preserves_link( + tmp_path: pathlib.Path, +) -> None: + """Full save_config path should preserve symlinks end-to-end.""" + target_dir = tmp_path / "dotfiles" + target_dir.mkdir() + real_file = target_dir / ".vcspull.yaml" + real_file.write_text("~/old/: {}\n", encoding="utf-8") + + link_path = tmp_path / ".vcspull.yaml" + link_path.symlink_to(real_file) + + data = {"~/code/": {"myrepo": {"repo": "git+https://example.com/repo.git"}}} + save_config(link_path, data) + + assert link_path.is_symlink() + assert link_path.resolve() == real_file.resolve() + assert "myrepo" in real_file.read_text(encoding="utf-8")