Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Changelog
- [core] Fixed ``generate_sub_moved_events()`` corrupting paths when the directory name appears multiple times in the path. (`#1158 <https://github.com/gorakhargosh/watchdog/pull/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 <https://github.com/gorakhargosh/watchdog/pull/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 <https://github.com/gorakhargosh/watchdog/pull/1176>`__)

6.0.0
~~~~~
Expand Down
12 changes: 10 additions & 2 deletions src/watchdog/utils/dirsnapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,23 @@ 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))

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))

Expand Down
33 changes: 32 additions & 1 deletion tests/test_snapshot_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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()
Expand Down