From a0fcba1cc4e3981d96bfbcf41593e3eb33f14e59 Mon Sep 17 00:00:00 2001 From: Apoorv Darshan Date: Mon, 6 Jul 2026 14:42:52 +0530 Subject: [PATCH] Fixed #1110 -- Do not report symlink create/delete as a move in DirectorySnapshotDiff A new symlink shares its target's inode because the default stat function follows symlinks. The move-detection heuristic matched that shared inode and reported the creation of a symlink (or the removal of one whose target remains) as a move of the target file, so PollingObserver emitted a spurious FileMovedEvent instead of the create/delete event the native observers emit. Only treat an inode match as a move when the counterpart path is genuinely new (destination) or gone (source), leaving the move-replace behaviour intact. Added regression tests for both the create and delete symlink cases. --- changelog.rst | 1 + src/watchdog/utils/dirsnapshot.py | 12 +++++++++-- tests/test_snapshot_diff.py | 33 ++++++++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) 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()