Skip to content

fix(config): preserve symlinks when writing config files#538

Merged
tony merged 12 commits intomasterfrom
symlinks
Mar 29, 2026
Merged

fix(config): preserve symlinks when writing config files#538
tony merged 12 commits intomasterfrom
symlinks

Conversation

@tony
Copy link
Copy Markdown
Member

@tony tony commented Mar 29, 2026

Summary

Resolves #537.

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. This PR fixes the full write and format-detection path:

  • _atomic_write(): resolve symlinks before creating the temp file and renaming, so the rename replaces the real target file — the symlink directory entry is preserved
  • normalize_config_file_path(): new helper that expands ~ and env vars without calling .resolve(), so the logical config name (e.g. .vcspull.yaml) is preserved for display and format detection
  • config_format_from_path(): new helper that inspects the symlink target's extension when present, so a .yaml symlink pointing to a .json file serialises correctly as JSON
  • CLI commands (add, discover, fmt, import): switch from .expanduser().resolve() to normalize_config_file_path() so symlinked configs are handled correctly throughout

Test plan

  • test_atomic_write_through_symlink_preserves_symlink — symlink survives, target updated, no temp file leftovers
  • test_atomic_write_through_symlink_preserves_permissions — file mode preserved through symlink write
  • test_save_config_via_symlink_preserves_link — end-to-end through save_config()
  • test_find_home_config_files_preserves_symlink_suffix — returned path keeps logical .yaml/.json suffix
  • test_config_format_from_path_prefers_supported_symlink_target.yaml.json symlink returns "json"
  • test_add_repo_uses_home_symlink_config_without_losing_yaml_suffix — add round-trip through extensionless symlink target
  • test_add_repo_uses_target_json_format_for_home_symlink — add correctly writes JSON when target is .json
  • test_resolve_config_file_accepts_extensionless_symlink_alias — explicit -f path inherits target format
  • Full test suite (1371 tests), ruff, mypy all pass

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 29, 2026

Codecov Report

❌ Patch coverage is 87.09677% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.27%. Comparing base (955de79) to head (fbb8fd1).
⚠️ Report is 13 commits behind head on master.

Files with missing lines Patch % Lines
src/vcspull/config.py 80.00% 1 Missing and 2 partials ⚠️
src/vcspull/_internal/config_reader.py 90.90% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #538      +/-   ##
==========================================
+ Coverage   84.24%   84.27%   +0.03%     
==========================================
  Files          29       29              
  Lines        3795     3809      +14     
  Branches      756      758       +2     
==========================================
+ Hits         3197     3210      +13     
+ Misses        378      377       -1     
- Partials      220      222       +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tony tony marked this pull request as ready for review March 29, 2026 10:02
@tony
Copy link
Copy Markdown
Member Author

tony commented Mar 29, 2026

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

tony added 3 commits March 29, 2026 05:40
why: `Path.replace()` operates on directory entries, not file contents.
When the target is a symlink, the symlink itself gets replaced by the
temp file — the real target file is left stale. This breaks dotfile
setups where `~/.vcspull.yaml` symlinks to a managed location.
what:
- Resolve target path before creating temp file and renaming
- Temp file now created next to the real destination (also fixes
  latent cross-device rename when symlink spans filesystems)
why: When no `-f` flag is given, config paths returned by
`find_home_config_files()` used `.expanduser()` only — the symlink
path was passed downstream. The `-f` codepaths already use
`.expanduser().resolve()`. This makes discovery consistent.
what:
- Add `.resolve()` after `.expanduser()` for both YAML and JSON paths
why: No existing tests verified that config writes through symlinks
preserve the link. These tests prevent future regressions.
what:
- Test _atomic_write preserves symlink and updates real target
- Test _atomic_write preserves file permissions through symlink
- Test save_config end-to-end through symlink
- Test find_home_config_files returns resolved path for symlinks
tony added 4 commits March 29, 2026 09:31
… behavior

why: _atomic_write had no doctest coverage. Inline examples show basic
usage and the symlink-preservation guarantee introduced in this branch.
what:
- Add doctest for basic atomic write
- Add doctest demonstrating symlink preservation
…add param/return sections

why: Docstring did not reflect the behavioral change from adding
`.resolve()`, and was missing NumPy-style parameter/return sections.
what:
- Note that returned paths are resolved through symlinks
- Add Parameters and Returns sections per NumPy docstring standard
why: find_home_config_files had no doctest coverage. Inline example
shows the empty-return case when no home config exists.
what:
- Add doctest demonstrating empty return when no home config exists
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
tony added 4 commits March 29, 2026 10:08
why: The first symlink-path fix preserved visible config names, which fixed
extensionless dotfile targets but broke alias paths and mismatched symlinks.
An explicit alias like `vcspull-config -> .vcspull.yaml` was rejected for
having no suffix, and a home link like `.vcspull.yaml -> vcspull.json` could
be rewritten with YAML because later format checks only saw the visible path.
what:
- Add shared config-format detection that prefers a symlink target's
  supported suffix and otherwise falls back to the visible path suffix
- Use that helper for config reads, duplicate-aware YAML loading, save
  dispatch, ordered-item saves, and import config-path validation
- Add regressions for target-format precedence, extensionless alias paths,
  and end-to-end JSON writes through a symlinked home config
why: Both functions lacked Examples sections despite the project requiring
working doctests on all public functions.
what:
- Add Examples section to save_config_yaml with round-trip read check
- Add Examples section to save_config_json with json.loads round-trip check
…ctest

why: Function had only a one-line summary with no Parameters, Returns, or
Examples section despite the project requiring full NumPy docstrings and
working doctests on all public functions.
what:
- Add description explaining why this differs from save_config_yaml (preserves
  duplicate top-level keys via ordered pairs)
- Add Parameters and Returns sections per NumPy docstring standard
- Add Examples section with duplicate-section round-trip doctest
… landed

why: The trigger condition in the TODO ("once the new vcspull add flow
lands") was already satisfied — vcspull add now uses DuplicateAwareConfigReader
for reading. The stale "Revisit once" sentence misled readers into thinking
the integration was still pending.
what:
- Replace the stale "Revisit once the new vcspull add flow lands" sentence
  with a note reflecting the current state
- Note that this basic loader remains for simpler read contexts
- Identify Option 1 (shared utility) as the cleanest long-term path
why: Document the symlink-preservation fix and format-detection
improvement shipped in this branch.
what:
- Add Bug fixes entry under v1.59.x unreleased section
@tony tony merged commit d631ca4 into master Mar 29, 2026
8 checks passed
@tony tony deleted the symlinks branch March 29, 2026 15:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Config writes destroy symlinks at ~/.vcspull.yaml

1 participant