Skip to content

Commit 927c308

Browse files
committed
fix(config[symlinked-config]) preserve logical config file type
why: Symlinked config entries in `$HOME` or via `-f/--file` can point to extensionless or differently named targets. Resolving those paths before format detection changes the suffix that downstream code sees, which can make vcspull parse or save with the wrong format or reject supported symlinked dotfile setups outright. what: - Add a shared config-path normalizer that expands paths without resolving symlinks - Use the logical config path for home discovery and explicit config path handling in add, discover, fmt, and import - Keep `_atomic_write()` following the real target so writes still update the destination file while preserving the symlink entry - Add regressions for home config discovery, explicit config path resolution, and end-to-end `vcspull add` behavior through a symlinked home config
1 parent 18da738 commit 927c308

8 files changed

Lines changed: 132 additions & 16 deletions

File tree

src/vcspull/cli/add.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
save_config,
2627
save_config_json,
2728
save_config_yaml_with_items,
@@ -508,7 +509,9 @@ def add_repo(
508509
# Determine config file
509510
config_file_path: pathlib.Path
510511
if config_file_path_str:
511-
config_file_path = pathlib.Path(config_file_path_str).expanduser().resolve()
512+
config_file_path = normalize_config_file_path(
513+
pathlib.Path(config_file_path_str)
514+
)
512515
else:
513516
home_configs = find_home_config_files(filetype=["yaml"])
514517
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: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
get_pin_reason,
3434
is_pinned_for_op,
3535
merge_duplicate_workspace_roots,
36+
normalize_config_file_path,
3637
save_config,
3738
workspace_root_label,
3839
)
@@ -560,10 +561,10 @@ def _resolve_config_file(config_path_str: str | None) -> pathlib.Path:
560561
Returns
561562
-------
562563
pathlib.Path
563-
Resolved config file path
564+
Absolute config file path
564565
"""
565566
if config_path_str:
566-
path = pathlib.Path(config_path_str).expanduser().resolve()
567+
path = normalize_config_file_path(pathlib.Path(config_path_str))
567568
if path.suffix.lower() not in {".yaml", ".yml", ".json"}:
568569
msg = f"Unsupported config file type: {path.suffix}"
569570
raise ValueError(msg)

src/vcspull/config.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,48 @@ def expand_dir(
5454
return dir_
5555

5656

57+
def normalize_config_file_path(
58+
path: pathlib.Path,
59+
cwd: pathlib.Path | Callable[[], pathlib.Path] = pathlib.Path.cwd,
60+
) -> pathlib.Path:
61+
"""Return absolute config file path without resolving symlinks.
62+
63+
The logical filename determines config format, so this keeps the
64+
symlink entry name intact while still expanding ``~`` and relative
65+
paths.
66+
67+
Parameters
68+
----------
69+
path : pathlib.Path
70+
Config file path to normalize.
71+
cwd : pathlib.Path, optional
72+
Current working dir (used to resolve relative paths). Defaults to
73+
:py:meth:`pathlib.Path.cwd`.
74+
75+
Returns
76+
-------
77+
pathlib.Path
78+
Absolute config file path with symlink names preserved.
79+
80+
Examples
81+
--------
82+
>>> normalize_config_file_path(pathlib.Path("~/cfg.yaml")).name
83+
'cfg.yaml'
84+
>>> normalize_config_file_path(
85+
... pathlib.Path("configs/vcspull.yaml"),
86+
... cwd=pathlib.Path("/tmp/project"),
87+
... )
88+
PosixPath('/tmp/project/configs/vcspull.yaml')
89+
"""
90+
path = pathlib.Path(os.path.expandvars(str(path))).expanduser()
91+
if callable(cwd):
92+
cwd = cwd()
93+
94+
if not path.is_absolute():
95+
path = (cwd / path).absolute()
96+
return path
97+
98+
5799
def _validate_worktrees_config(
58100
worktrees_raw: t.Any,
59101
repo_name: str,
@@ -351,8 +393,9 @@ def find_home_config_files(
351393
) -> list[pathlib.Path]:
352394
"""Return configs of ``.vcspull.{yaml,json}`` in user's home directory.
353395
354-
Paths are resolved through symlinks so callers receive the real
355-
filesystem location, consistent with the ``-f`` flag codepath.
396+
The returned path preserves the logical home entry name so callers
397+
keep the config type implied by ``.yaml`` or ``.json`` even when the
398+
file is a symlink.
356399
357400
Parameters
358401
----------
@@ -362,7 +405,7 @@ def find_home_config_files(
362405
Returns
363406
-------
364407
list of pathlib.Path
365-
Resolved paths to discovered config files
408+
Absolute paths to discovered config files
366409
367410
Examples
368411
--------
@@ -376,9 +419,9 @@ def find_home_config_files(
376419
check_yaml = "yaml" in filetype
377420
check_json = "json" in filetype
378421

379-
yaml_config = pathlib.Path("~/.vcspull.yaml").expanduser().resolve()
422+
yaml_config = normalize_config_file_path(pathlib.Path("~/.vcspull.yaml"))
380423
has_yaml_config = check_yaml and yaml_config.exists()
381-
json_config = pathlib.Path("~/.vcspull.json").expanduser().resolve()
424+
json_config = normalize_config_file_path(pathlib.Path("~/.vcspull.json"))
382425
has_json_config = check_json and json_config.exists()
383426

384427
if not has_yaml_config and not has_json_config:

tests/cli/test_add.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,43 @@ def test_add_repo_creates_new_file(
330330
assert "newrepo" in config["~/"]
331331

332332

333+
def test_add_repo_uses_home_symlink_config_without_losing_yaml_suffix(
334+
tmp_path: pathlib.Path,
335+
monkeypatch: MonkeyPatch,
336+
) -> None:
337+
"""Default home config symlinks should still load and save as YAML."""
338+
monkeypatch.setenv("HOME", str(tmp_path))
339+
monkeypatch.chdir(tmp_path)
340+
341+
dotfiles_dir = tmp_path / "dotfiles"
342+
dotfiles_dir.mkdir()
343+
344+
real_config = dotfiles_dir / "vcspull-config"
345+
real_config.write_text("~/code/: {}\n", encoding="utf-8")
346+
347+
symlink = tmp_path / ".vcspull.yaml"
348+
symlink.symlink_to(real_config)
349+
350+
repo_path = tmp_path / "code" / "newrepo"
351+
repo_path.mkdir(parents=True)
352+
353+
add_repo(
354+
name="newrepo",
355+
url="git+https://github.com/user/newrepo.git",
356+
config_file_path_str=None,
357+
path=str(repo_path),
358+
workspace_root_path="~/code/",
359+
dry_run=False,
360+
)
361+
362+
assert symlink.is_symlink()
363+
assert symlink.resolve() == real_config.resolve()
364+
365+
config_text = real_config.read_text(encoding="utf-8")
366+
assert "newrepo" in config_text
367+
assert "git+https://github.com/user/newrepo.git" in config_text
368+
369+
333370
def test_add_repo_invalid_config_logs_private_path(
334371
user_path: pathlib.Path,
335372
caplog: pytest.LogCaptureFixture,

tests/cli/test_import_repos.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,29 @@ def test_resolve_config_file(
188188
assert result.name == expected_suffix
189189

190190

191+
def test_resolve_config_file_preserves_symlink_suffix(
192+
tmp_path: pathlib.Path,
193+
monkeypatch: MonkeyPatch,
194+
) -> None:
195+
"""Explicit config paths should keep symlink suffixes for format detection."""
196+
monkeypatch.setenv("HOME", str(tmp_path))
197+
198+
dotfiles_dir = tmp_path / "dotfiles"
199+
dotfiles_dir.mkdir()
200+
201+
real_config = dotfiles_dir / "vcspull-config"
202+
real_config.write_text("~/repos/: {}\n", encoding="utf-8")
203+
204+
symlink = tmp_path / ".vcspull.yaml"
205+
symlink.symlink_to(real_config)
206+
207+
result = _resolve_config_file(str(symlink))
208+
209+
assert result == symlink
210+
assert result.suffix == ".yaml"
211+
assert result.resolve() == real_config.resolve()
212+
213+
191214
class ImportReposFixture(t.NamedTuple):
192215
"""Fixture for _run_import test cases."""
193216

tests/test_config_file.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -244,11 +244,13 @@ def test_find_home_config_files_both_types_still_raises(
244244
config.find_home_config_files()
245245

246246

247-
def test_find_home_config_files_resolves_symlink(tmp_path: pathlib.Path) -> None:
248-
"""Symlinked ~/.vcspull.yaml should be resolved to the real target path."""
247+
def test_find_home_config_files_preserves_symlink_suffix(
248+
tmp_path: pathlib.Path,
249+
) -> None:
250+
"""Symlinked home configs should keep the logical suffix for format detection."""
249251
dotfiles_dir = tmp_path / ".dot-config"
250252
dotfiles_dir.mkdir()
251-
real_file = dotfiles_dir / ".vcspull.yaml"
253+
real_file = dotfiles_dir / "vcspull-config"
252254
real_file.write_text("~/code/: {}\n", encoding="utf-8")
253255

254256
symlink = tmp_path / ".vcspull.yaml"
@@ -259,9 +261,10 @@ def test_find_home_config_files_resolves_symlink(tmp_path: pathlib.Path) -> None
259261
results = config.find_home_config_files()
260262

261263
assert len(results) == 1
262-
# Must be the resolved (real) path, not the symlink
263-
assert results[0] == real_file.resolve()
264-
assert not results[0].is_symlink()
264+
assert results[0] == symlink
265+
assert results[0].suffix == ".yaml"
266+
assert results[0].is_symlink()
267+
assert results[0].resolve() == real_file.resolve()
265268

266269

267270
def test_in_dir(

0 commit comments

Comments
 (0)