Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ $ uvx --from 'vcspull' --prerelease allow vcspull
_Notes on upcoming releases will be added here_
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### 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
Expand Down
64 changes: 56 additions & 8 deletions src/vcspull/_internal/config_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]


Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
12 changes: 9 additions & 3 deletions src/vcspull/cli/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion src/vcspull/cli/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion src/vcspull/cli/fmt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 8 additions & 4 deletions src/vcspull/cli/import_cmd/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -33,6 +36,7 @@
get_pin_reason,
is_pinned_for_op,
merge_duplicate_workspace_roots,
normalize_config_file_path,
save_config,
workspace_root_label,
)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading