From fec0c6845690756f7bbff403aedf8e2795aa8c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Furga=C5=82a?= <83299832+00200200@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:48:27 +0200 Subject: [PATCH 1/2] Overwrite existing dest entries when merge_tree copies a symlink. merge_tree documents that it overwrites existing contents, but a second sync with symlinks=True raises FileExistsError. Remove the destination first so re-merging a tree with links succeeds. Fixes #263 --- path/__init__.py | 8 ++++++-- tests/test_path.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/path/__init__.py b/path/__init__.py index 33dd979..8557720 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -1662,8 +1662,12 @@ def ignored(item: Self) -> bool: for source in itertools.filterfalse(ignored, sources): dest = dst_path / source.name if symlinks and source.islink(): - target = source.readlink() - target.symlink(dest) + # copy2 overwrites files; replace dest so a re-merge does not fail + if dest.islink() or dest.is_file(): + dest.remove() + elif dest.is_dir(): + dest.rmtree() + source.readlink().symlink(dest) elif source.is_dir(): source.merge_tree( dest, diff --git a/tests/test_path.py b/tests/test_path.py index b424bb6..3877dd6 100644 --- a/tests/test_path.py +++ b/tests/test_path.py @@ -919,6 +919,39 @@ def test_with_nonexisting_dst_args(self): assert set(self.subdir_b.iterdir()) == expected self.check_link() + def test_overwrite_existing_symlink(self): + """ + Re-merging a tree that contains symlinks should overwrite + destination links instead of raising FileExistsError. + """ + self.subdir_a.merge_tree(self.subdir_b, True) + self.subdir_a.merge_tree(self.subdir_b, True) + self.check_link() + + def test_overwrite_retargets_existing_symlink(self): + self.subdir_a.merge_tree(self.subdir_b, True) + self.test_link.remove() + other = self.subdir_a / 'other.txt' + other.write_text('y', encoding='utf-8') + other.symlink(self.test_link) + self.subdir_a.merge_tree(self.subdir_b, True) + dest_link = self.subdir_b / self.test_link.name + assert dest_link.islink() + assert dest_link.readlink().basename() == other.name + + def test_symlink_replaces_existing_file(self): + dest_link = self.subdir_b / self.test_link.name + dest_link.write_text('not a link', encoding='utf-8') + self.subdir_a.merge_tree(self.subdir_b, True) + assert dest_link.islink() + + def test_symlink_replaces_existing_directory(self): + dest_link = self.subdir_b / self.test_link.name + dest_link.mkdir() + (dest_link / 'child').touch() + self.subdir_a.merge_tree(self.subdir_b, True) + assert dest_link.islink() + def test_with_existing_dst(self): self.subdir_b.rmtree() self.subdir_a.copytree(self.subdir_b, True) From 40453462411bfb37661b507bc0f94a0ec47e80cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Furga=C5=82a?= <83299832+00200200@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:43:10 +0100 Subject: [PATCH 2/2] Reject merge_tree copies onto the source directory --- path/__init__.py | 5 +++++ tests/test_path.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/path/__init__.py b/path/__init__.py index 8557720..d4885a8 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -1643,6 +1643,9 @@ def merge_tree( Pass ``symlinks=True`` to copy symbolic links as links. + Raise :class:`shutil.SameFileError` if self and dst refer to + the same directory, including through a symbolic link. + Accepts a ``copy_function``, similar to copytree. To avoid overwriting newer files, supply a copy function @@ -1651,6 +1654,8 @@ def merge_tree( src.merge_tree(dst, copy_function=only_newer(shutil.copy2)) """ dst_path = self._next_class(dst) + if dst_path.exists() and self.samefile(dst_path): + raise shutil.SameFileError(f'{self!r} and {dst!r} are the same directory') dst_path.makedirs_p() sources = list(self.iterdir()) diff --git a/tests/test_path.py b/tests/test_path.py index 3877dd6..9d8f39b 100644 --- a/tests/test_path.py +++ b/tests/test_path.py @@ -952,6 +952,31 @@ def test_symlink_replaces_existing_directory(self): self.subdir_a.merge_tree(self.subdir_b, True) assert dest_link.islink() + @pytest.mark.parametrize('alias', [False, True]) + @pytest.mark.parametrize('dangling', [False, True]) + def test_self_merge_preserves_source_symlink(self, alias, dangling): + source = self.test_dir / 'source' + source.mkdir() + target = self.test_dir / 'target' + if not dangling: + target.write_text('original', encoding='utf-8') + link = source / 'link' + target.symlink(link) + destination = source + if alias: + destination = self.test_dir / 'alias' + destination.symlink_to(source, target_is_directory=True) + + with pytest.raises(shutil.SameFileError): + source.merge_tree(destination, symlinks=True) + + assert link.islink() + assert link.readlink() == target + if dangling: + assert not target.exists() + else: + assert target.read_text(encoding='utf-8') == 'original' + def test_with_existing_dst(self): self.subdir_b.rmtree() self.subdir_a.copytree(self.subdir_b, True)