diff --git a/changelog.rst b/changelog.rst index 95855aba..988f2e7d 100644 --- a/changelog.rst +++ b/changelog.rst @@ -26,6 +26,7 @@ Changelog - [core] Fixed ``generate_sub_moved_events()`` corrupting paths when the directory name appears multiple times in the path. (`#1158 `__) - Thanks to our beloved contributors: @BoboTiG, @tybug, @Corentin-pro, @kirkhansen, @JoachimCoenen, @blitztide - [core] Call ``task_done()`` for the stop sentinel in ``dispatch_events()`` to prevent ``join()`` from hanging. (`#1159 `__) +- [utils] Fixed ``DirectorySnapshotDiff`` reporting a newly created symlink as a move of its target (and a removed symlink as a move onto its target), so ``PollingObserver`` now emits a create/delete event to match the native observers. (`#1176 `__) 6.0.0 ~~~~~ diff --git a/src/watchdog/utils/dirsnapshot.py b/src/watchdog/utils/dirsnapshot.py index e7e51b50..e983ba5c 100644 --- a/src/watchdog/utils/dirsnapshot.py +++ b/src/watchdog/utils/dirsnapshot.py @@ -98,7 +98,11 @@ def get_inode(directory: DirectorySnapshot, full_path: bytes | str) -> int | tup for path in set(deleted): inode = ref.inode(path) new_path = snapshot.path(inode) - if new_path: + # Only treat this as a move if the inode is now reachable through a + # genuinely new path. A symlink (or hard link) shares its target's + # inode, so ``new_path`` may still point at the unchanged target, + # which is not a move. + if new_path and (new_path not in ref.paths or ref.inode(new_path) != inode): # file is not deleted but moved deleted.remove(path) moved.add((path, new_path)) @@ -106,7 +110,11 @@ def get_inode(directory: DirectorySnapshot, full_path: bytes | str) -> int | tup for path in set(created): inode = snapshot.inode(path) old_path = ref.path(inode) - if old_path: + # Only treat this as a move if the inode's previous path is really + # gone. A symlink (or hard link) shares its target's inode, so + # ``old_path`` may still exist with that inode, which means this is a + # creation, not a move. + if old_path and (old_path not in snapshot.paths or snapshot.inode(old_path) != inode): created.remove(path) moved.add((old_path, path)) diff --git a/tests/test_snapshot_diff.py b/tests/test_snapshot_diff.py index e5ea8489..2083bace 100644 --- a/tests/test_snapshot_diff.py +++ b/tests/test_snapshot_diff.py @@ -6,10 +6,12 @@ import time from unittest.mock import patch +import pytest + from watchdog.utils import platform from watchdog.utils.dirsnapshot import DirectorySnapshot, DirectorySnapshotDiff, EmptyDirectorySnapshot -from .shell import mkdir, mv, rm, touch +from .shell import mkdir, mv, rm, symlink, touch def wait(): @@ -90,6 +92,35 @@ def test_move_replace(p): assert diff.files_created == [] +@pytest.mark.skipif(platform.is_windows(), reason="Creating symlinks requires privileges on Windows.") +def test_create_symlink_to_existing_file(p): + # A new symlink shares its target's inode (the default stat follows symlinks), + # so it must not be mistaken for a move of the still-present target. See #1110. + mkdir(p("dir")) + touch(p("dir", "a")) + ref = DirectorySnapshot(p("")) + symlink("a", p("dir", "a.link")) + diff = DirectorySnapshotDiff(ref, DirectorySnapshot(p(""))) + assert diff.files_created == [p("dir", "a.link")] + assert diff.files_moved == [] + assert diff.files_deleted == [] + + +@pytest.mark.skipif(platform.is_windows(), reason="Creating symlinks requires privileges on Windows.") +def test_delete_symlink_to_existing_file(p): + # Removing a symlink whose target remains must be reported as a deletion, not + # a move onto the target that keeps the shared inode. See #1110. + mkdir(p("dir")) + touch(p("dir", "a")) + symlink("a", p("dir", "a.link")) + ref = DirectorySnapshot(p("")) + rm(p("dir", "a.link")) + diff = DirectorySnapshotDiff(ref, DirectorySnapshot(p(""))) + assert diff.files_deleted == [p("dir", "a.link")] + assert diff.files_moved == [] + assert diff.files_created == [] + + def test_dir_modify_on_create(p): ref = DirectorySnapshot(p("")) wait()