Skip to content

Commit d631ca4

Browse files
authored
fix(config): preserve symlinks when writing config files (#538)
Config files that are symbolic links (e.g. ~/.vcspull.yaml → ~/dotfiles/vcspull.yaml) were silently replaced by regular files on every write. os.rename() on a symlink path replaces the symlink entry itself, so each save destroyed the link and left a bare file behind. The write path now resolves the symlink before creating the temp file, so the atomic rename targets the real file and the symlink directory entry is left intact. A second related fix stops callers from calling .resolve() when normalising config paths. Dereferencing the symlink there strips the logical .yaml/.json suffix used for format detection, causing further corruption downstream. A new normalize_config_file_path() helper expands ~ and env vars without following symlinks, and all CLI commands (add, discover, fmt, import) now use it. - **_atomic_write()**: create temp file next to the resolved target, rename into the resolved path — symlink entry preserved - **normalize_config_file_path()**: new helper — expands paths without .resolve() so the logical config name is retained - **config_format_from_path()**: new helper — inspects the symlink target's extension so a .yaml symlink pointing to a .json file serialises correctly as JSON - **CLI commands**: switch from .expanduser().resolve() to normalize_config_file_path() throughout Fixes #537
2 parents 955de79 + fbb8fd1 commit d631ca4

11 files changed

Lines changed: 450 additions & 32 deletions

File tree

CHANGES

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,20 @@ $ uvx --from 'vcspull' --prerelease allow vcspull
3737
_Notes on upcoming releases will be added here_
3838
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->
3939

40+
### Bug fixes
41+
42+
#### `config`: Preserve symlinks when writing config files (#538)
43+
44+
Config files that are symbolic links (e.g. `~/.vcspull.yaml` pointing to a
45+
dotfiles directory) were being silently replaced by regular files on every
46+
write, destroying the symlink. The write now goes through the link — a temp
47+
file is created next to the real target, renamed into place, and the symlink
48+
directory entry is preserved.
49+
50+
Format detection now also inspects the symlink target's extension, so a
51+
`.yaml` symlink pointing to a `.json` file serialises correctly as JSON rather
52+
than overwriting the target with YAML.
53+
4054
## vcspull v1.58.0 (2026-03-01)
4155

4256
### New features

src/vcspull/_internal/config_reader.py

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,56 @@
88
import yaml
99

1010
FormatLiteral = t.Literal["json", "yaml"]
11+
_SUPPORTED_CONFIG_SUFFIXES: dict[str, FormatLiteral] = {
12+
".json": "json",
13+
".yaml": "yaml",
14+
".yml": "yaml",
15+
}
16+
17+
18+
def config_format_from_path(path: pathlib.Path) -> FormatLiteral | None:
19+
"""Return config format inferred from a path or symlink target.
20+
21+
The visible path remains the config identity, but symlinks may point
22+
at a differently named target. When the target advertises a supported
23+
config suffix, prefer that format; otherwise fall back to the visible
24+
path suffix.
25+
26+
Parameters
27+
----------
28+
path : pathlib.Path
29+
Path to inspect.
30+
31+
Returns
32+
-------
33+
FormatLiteral | None
34+
``"json"`` or ``"yaml"`` when a supported suffix is found,
35+
otherwise ``None``.
36+
37+
Examples
38+
--------
39+
>>> config_format_from_path(pathlib.Path("config.yaml"))
40+
'yaml'
41+
>>> import tempfile
42+
>>> with tempfile.TemporaryDirectory() as tmp:
43+
... root = pathlib.Path(tmp)
44+
... target = root / "config.json"
45+
... _ = target.write_text("{}", encoding="utf-8")
46+
... link = root / ".vcspull.yaml"
47+
... link.symlink_to(target)
48+
... config_format_from_path(link)
49+
'json'
50+
"""
51+
path_format = _SUPPORTED_CONFIG_SUFFIXES.get(path.suffix.lower())
52+
53+
target_format: FormatLiteral | None = None
54+
if path.is_symlink():
55+
resolved = path.resolve(strict=False)
56+
target_format = _SUPPORTED_CONFIG_SUFFIXES.get(resolved.suffix.lower())
57+
58+
return target_format or path_format
59+
60+
1161
RawConfigData: t.TypeAlias = dict[t.Any, t.Any]
1262

1363

@@ -114,14 +164,12 @@ def _from_file(cls, path: pathlib.Path) -> dict[str, t.Any]:
114164
# the formatter helpers directly;
115165
# 3) Keep this basic loader but add an opt-in path for duplicate-aware
116166
# parsing so commands like ``vcspull add`` can avoid data loss.
117-
# Revisit once the new ``vcspull add`` flow lands so both commands share
118-
# the same duplication safeguards.
167+
# ``vcspull add`` now uses ``DuplicateAwareConfigReader`` for reading
168+
# (see ``cli/add.py``). This basic loader remains for simpler read
169+
# contexts. Option 1 (shared utility) is the cleanest long-term path.
119170

120-
if path.suffix in {".yaml", ".yml"}:
121-
fmt: FormatLiteral = "yaml"
122-
elif path.suffix == ".json":
123-
fmt = "json"
124-
else:
171+
fmt = config_format_from_path(path)
172+
if fmt is None:
125173
msg = f"{path.suffix} not supported in {path}"
126174
raise NotImplementedError(msg)
127175

@@ -335,7 +383,7 @@ def _load_from_path(
335383
cls,
336384
path: pathlib.Path,
337385
) -> tuple[dict[str, t.Any], dict[str, list[t.Any]], list[tuple[str, t.Any]]]:
338-
if path.suffix.lower() in {".yaml", ".yml"}:
386+
if config_format_from_path(path) == "yaml":
339387
content = path.read_text(encoding="utf-8")
340388
return cls._load_yaml_with_duplicates(content)
341389

src/vcspull/cli/add.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313

1414
from colorama import Fore, Style
1515

16-
from vcspull._internal.config_reader import DuplicateAwareConfigReader
16+
from vcspull._internal.config_reader import (
17+
DuplicateAwareConfigReader,
18+
config_format_from_path,
19+
)
1720
from vcspull._internal.private_path import PrivatePath
1821
from vcspull.config import (
1922
canonicalize_workspace_path,
@@ -22,6 +25,7 @@
2225
get_pin_reason,
2326
is_pinned_for_op,
2427
merge_duplicate_workspace_roots,
28+
normalize_config_file_path,
2529
save_config,
2630
save_config_json,
2731
save_config_yaml_with_items,
@@ -340,7 +344,7 @@ def _save_ordered_items(
340344
>>> "~/code/" in data
341345
True
342346
"""
343-
if config_file_path.suffix.lower() == ".json":
347+
if config_format_from_path(config_file_path) == "json":
344348
save_config_json(
345349
config_file_path,
346350
_collapse_ordered_items_to_dict(ordered_items),
@@ -508,7 +512,9 @@ def add_repo(
508512
# Determine config file
509513
config_file_path: pathlib.Path
510514
if config_file_path_str:
511-
config_file_path = pathlib.Path(config_file_path_str).expanduser().resolve()
515+
config_file_path = normalize_config_file_path(
516+
pathlib.Path(config_file_path_str)
517+
)
512518
else:
513519
home_configs = find_home_config_files(filetype=["yaml"])
514520
if not home_configs:

src/vcspull/cli/discover.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
get_pin_reason,
2323
is_pinned_for_op,
2424
merge_duplicate_workspace_roots,
25+
normalize_config_file_path,
2526
normalize_workspace_roots,
2627
save_config,
2728
workspace_root_label,
@@ -327,7 +328,9 @@ def discover_repos(
327328

328329
config_file_path: pathlib.Path
329330
if config_file_path_str:
330-
config_file_path = pathlib.Path(config_file_path_str).expanduser().resolve()
331+
config_file_path = normalize_config_file_path(
332+
pathlib.Path(config_file_path_str)
333+
)
331334
else:
332335
home_configs = find_home_config_files(filetype=["yaml"])
333336
if not home_configs:

src/vcspull/cli/fmt.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
find_home_config_files,
2020
is_pinned_for_op,
2121
merge_duplicate_workspace_roots,
22+
normalize_config_file_path,
2223
normalize_workspace_roots,
2324
save_config,
2425
)
@@ -570,7 +571,9 @@ def format_config_file(
570571
else:
571572
# Format single config file
572573
if config_file_path_str:
573-
config_file_path = pathlib.Path(config_file_path_str).expanduser().resolve()
574+
config_file_path = normalize_config_file_path(
575+
pathlib.Path(config_file_path_str)
576+
)
574577
else:
575578
home_configs = find_home_config_files(filetype=["yaml"])
576579
if not home_configs:

src/vcspull/cli/import_cmd/_common.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
import sys
1515
import typing as t
1616

17-
from vcspull._internal.config_reader import DuplicateAwareConfigReader
17+
from vcspull._internal.config_reader import (
18+
DuplicateAwareConfigReader,
19+
config_format_from_path,
20+
)
1821
from vcspull._internal.private_path import PrivatePath
1922
from vcspull._internal.remotes import (
2023
AuthenticationError,
@@ -33,6 +36,7 @@
3336
get_pin_reason,
3437
is_pinned_for_op,
3538
merge_duplicate_workspace_roots,
39+
normalize_config_file_path,
3640
save_config,
3741
workspace_root_label,
3842
)
@@ -560,11 +564,11 @@ def _resolve_config_file(config_path_str: str | None) -> pathlib.Path:
560564
Returns
561565
-------
562566
pathlib.Path
563-
Resolved config file path
567+
Absolute config file path
564568
"""
565569
if config_path_str:
566-
path = pathlib.Path(config_path_str).expanduser().resolve()
567-
if path.suffix.lower() not in {".yaml", ".yml", ".json"}:
570+
path = normalize_config_file_path(pathlib.Path(config_path_str))
571+
if config_format_from_path(path) is None:
568572
msg = f"Unsupported config file type: {path.suffix}"
569573
raise ValueError(msg)
570574
return path

0 commit comments

Comments
 (0)