Skip to content

Commit 71c36b8

Browse files
committed
fix(config[_atomic_write]) resolve symlinks before atomic rename
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)
1 parent a27924c commit 71c36b8

1 file changed

Lines changed: 14 additions & 6 deletions

File tree

src/vcspull/config.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -668,28 +668,36 @@ def is_config_file(
668668
def _atomic_write(target: pathlib.Path, content: str) -> None:
669669
"""Write content to a file atomically via temp-file-then-rename.
670670
671+
If *target* is a symbolic link the write goes through the symlink:
672+
the temporary file is created next to the resolved destination and
673+
the rename replaces the resolved path, leaving the symlink intact.
674+
671675
Parameters
672676
----------
673677
target : pathlib.Path
674-
Destination file path
678+
Destination file path (may be a symlink)
675679
content : str
676680
Content to write
677681
"""
682+
# Resolve symlinks so the temp file lives next to the real
683+
# destination and the rename replaces the real file, not the symlink.
684+
resolved = target.resolve()
685+
678686
original_mode: int | None = None
679-
if target.exists():
680-
original_mode = target.stat().st_mode
687+
if resolved.exists():
688+
original_mode = resolved.stat().st_mode
681689

682690
fd, tmp_path = tempfile.mkstemp(
683-
dir=target.parent,
684-
prefix=f".{target.name}.",
691+
dir=resolved.parent,
692+
prefix=f".{resolved.name}.",
685693
suffix=".tmp",
686694
)
687695
try:
688696
with os.fdopen(fd, "w", encoding="utf-8") as f:
689697
f.write(content)
690698
if original_mode is not None:
691699
pathlib.Path(tmp_path).chmod(original_mode)
692-
pathlib.Path(tmp_path).replace(target)
700+
pathlib.Path(tmp_path).replace(resolved)
693701
except BaseException:
694702
# Clean up the temp file on any failure
695703
with contextlib.suppress(OSError):

0 commit comments

Comments
 (0)