Skip to content

fix(windows): coalesce the deletion reported before a case-only rename - #1204

Merged
BoboTiG merged 2 commits into
gorakhargosh:masterfrom
MohammedAlkindi:fix/windows-case-only-rename
Aug 11, 2026
Merged

BoboTiG merged 2 commits into
gorakhargosh:masterfrom
MohammedAlkindi:fix/windows-case-only-rename

Conversation

@MohammedAlkindi

Copy link
Copy Markdown
Contributor

Closes #752

Replaces #1203, which I closed by accident renaming the source branch. Same commit (95d4405), same diff — no content change.

Symptom

Renaming a file to a different case — file to FILE — emits two events on Windows instead of one:

FileDeletedEvent(src_path='...\file')
FileMovedEvent(src_path='...\file', dest_path='...\FILE')

A directory renamed the same way emits FileDeletedEvent followed by DirMovedEvent. Since the deletion is indistinguishable from a real one, consumers see a file vanish and then move.

Cause

The move event was never the problem — watchdog already builds it correctly from the rename pair. The extra deletion comes straight from the OS. Dumping the raw ReadDirectoryChangesW records on Windows 11 (3 runs each, identical every time):

operation records reported
file → FILE REMOVED(file), RENAMED_OLD_NAME(file), RENAMED_NEW_NAME(FILE)
a → b RENAMED_OLD_NAME(a), RENAMED_NEW_NAME(b)
sub → SUB (dir) REMOVED(sub), RENAMED_OLD_NAME(sub), RENAMED_NEW_NAME(SUB)

Windows prepends a FILE_ACTION_REMOVED for the old name only when the rename changes nothing but case. queue_events() translates each record independently, so that record became a deletion event.

Fix

DirectoryChangeReader.get_events() drops a deletion when the very next record renames that same path. Two properties make that condition safe rather than heuristic:

  • A rename that also changes the name reports the pair alone, so ordinary renames are untouched.
  • Nothing can rename a path that was genuinely deleted. For the records to be adjacent and share a path, the "deletion" cannot have been real — if a path is deleted, recreated and only then renamed, a CREATED record sits between them and the deletion is kept.

A deletion that ends the batch is also kept, so a rename split across two ReadDirectoryChangesW buffers degrades to today's behaviour rather than losing an event.

Deliberately unchanged

Cross-directory moves. tests/test_emitter.py::test_case_change — the test linked from the issue — moves dir1/file to dir2/FILE, and Windows reports that as REMOVED + CREATED + MODIFIED with no rename records at all. It reports dir1/a → dir2/b identically, so this is not case-related: the information needed to emit a move is simply absent, and recovering it would mean correlating unrelated deletions with later creations. That test is left exactly as it is, and this PR does not change cross-directory behaviour.

Testing

Run on Windows 11, Python 3.13.13.

  • tests/test_emitter.py::test_case_only_rename_on_windows — new. Renames a file and a directory by case only and asserts exactly one move event each. Fails before the fix with AssertionError: wrong number of events, having found FileDeletedEvent + FileMovedEvent; passes after.
  • tests/test_observers_winapi.py — new unit tests pinning the condition, including the three negative cases that must keep their deletion: deleted-recreated-then-renamed, a deletion followed by a rename of a different path, and a deletion ending the batch.
  • Full suite: 158 passed / 2 failed on a clean master checkout, 163 passed / 2 failed with this branch. The same two — test_renaming_top_level_directory and test_move_nested_subdirectories_on_windows — fail identically before and after (verified by stashing the change and re-running); they are pre-existing on Windows and untouched here.
  • ruff format --check, ruff check, and sphinx-build are clean.
  • mypy src docs/source/examples reports 11 errors both before and after — the same set, unused type: ignore comments plus POSIX-only attributes flagged when run on Windows. No new errors introduced.

I could not run the Linux or macOS jobs locally, so I left the new emitter test Windows-gated rather than assert behaviour I had not observed — the bug is a Windows-API artefact and the fix is confined to winapi.py. CI is the authority for the other two platforms.

Renaming a file to a different case, `file` to `FILE`, makes
ReadDirectoryChangesW report FILE_ACTION_REMOVED for the old name on top
of the usual RENAMED_OLD_NAME/RENAMED_NEW_NAME pair. Watchdog already
turned that pair into a move event, so a case-only rename surfaced as a
FileDeletedEvent followed by a FileMovedEvent, and a directory renamed
the same way as a FileDeletedEvent followed by a DirMovedEvent. Consumers
had no way to tell those deletions from real ones.

DirectoryChangeReader.get_events() now drops a deletion when the very
next record renames that same path. A rename that also changes the name
reports the pair alone, and nothing can rename a path that was genuinely
deleted, so the condition cannot swallow a real deletion; a deletion
ending the batch is left untouched.

Cross-directory moves are deliberately unchanged: Windows reports those
as a plain delete/create carrying no rename records at all, whether or
not the case differs, so they cannot be recovered here.

tests/test_emitter.py gains test_case_only_rename_on_windows, which fails
on the spurious FileDeletedEvent before the fix and passes after it.
tests/test_observers_winapi.py pins the negative cases directly.

Closes gorakhargosh#752
@MohammedAlkindi

Copy link
Copy Markdown
Contributor Author

CI note, so the red X isn't misread: the 2 failing jobs are 🐧 Linux 3.13t / 3.14t, both on tests/test_0_watchmedo.py::test_auto_restart_on_file_change_debounce - assert 2 == 3. That is pre-existing free-threaded flakiness, not this change — the latest master run (a0bb46c, the commit this branch is based on) fails the same test in the same job, and Tests has been red on 5 of the last 6 master runs. This PR touches only winapi.py, which is never imported on Linux.

The 24 other jobs pass, including every Windows one. On 🪟 Windows 3.14 the new tests all ran green:

tests/test_emitter.py::test_case_only_rename_on_windows PASSED
tests/test_observers_winapi.py::test_drop_case_only_rename_deletions PASSED
tests/test_observers_winapi.py::test_drop_case_only_rename_deletions_keeps_real_deletions[events0] PASSED
tests/test_observers_winapi.py::test_drop_case_only_rename_deletions_keeps_real_deletions[events1] PASSED
tests/test_observers_winapi.py::test_drop_case_only_rename_deletions_keeps_real_deletions[events2] PASSED
165 passed, 13 skipped, 1 xfailed, 1 xpassed

Happy to rebase if you land a fix for the debounce test and want a clean run.

Comment thread tests/test_emitter.py Outdated
ec.add(DirModifiedEvent, "dir2")


@pytest.mark.skipif(

@BoboTiG BoboTiG Aug 10, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, there is no need for a new test, but only to adapt test_case_change(). For instance, the check for Windows in the events list should disappear with your patch, correct?

if not platform.is_windows():
ec.add(FileMovedEvent, "dir1/file", dest_path="dir2/FILE")
ec.add(DirModifiedEvent, "dir1")
ec.add(DirModifiedEvent, "dir2")
else:
ec.add(FileDeletedEvent, "dir1/file")
ec.add(FileCreatedEvent, "dir2/FILE")
ec.add(DirModifiedEvent, "dir2")

Since the test still passes, I guess there is something missing?

In that same test, maybe could you add your tests ("file" -> "FILE", and "dir" -> "DIR"), add a subfolder renaming too, so that all cases are checked?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, and the answer is that nothing is missing — but test_case_change() is misleadingly named, which is what makes it look that way.

It does mv(p("dir1", "file"), p("dir2", "FILE")), which is a move between two directories that happens to also change the case. Windows reports a cross-directory move with no rename records at all, so there is nothing for the patch to coalesce and the Windows branch has to stay. Here are the raw ReadDirectoryChangesW actions, with the filter bypassed so this is what the OS actually hands us:

[1] dir1/file -> dir2/FILE   (what the test does)  DELETED, CREATED, MODIFIED
[2] dir1/a    -> dir2/b      (no case change)      DELETED, CREATED, MODIFIED
[3] other     -> OTHER       (in place)            DELETED, RENAMED_OLD_NAME, RENAMED_NEW_NAME
[4] subdir    -> SUBDIR      (in place)            DELETED, RENAMED_OLD_NAME, RENAMED_NEW_NAME
[5] dir1/sub  -> dir1/SUB    (in place)            MODIFIED, DELETED, RENAMED_OLD_NAME, RENAMED_NEW_NAME, MODIFIED

[1] and [2] are byte-for-byte the same shape, which is the proof: the existing test's behaviour has nothing to do with case. The extra DELETED only appears on the in-place renames, [3] to [5], and that is the one this PR drops.

So the test passing before and after is correct, not a symptom.

I have folded everything into test_case_change() as you asked and dropped the separate test. It now runs the original cross-directory move unchanged, then three in-place renames: a file, a directory, and a subfolder one level down. Checking it has teeth — with winapi.py reverted to master:

$ git checkout upstream/master -- src/watchdog/observers/winapi.py
$ python -m pytest tests/test_emitter.py::test_case_change
E   AssertionError: wrong number of events
found_events = [FileDeletedEvent(src_path='...\test_case_change0\other'),
                FileMovedEvent(src_path='...\other', dest_path='...\OTHER')]
1 failed

and restored, 1 passed. Full suite here: 2 failed, 162 passed, 13 skipped on Windows 11 / Python 3.13.13 — the two failures are test_renaming_top_level_directory and test_move_nested_subdirectories_on_windows, which fail the same way on a clean master checkout on this machine. ruff format --check and ruff check are clean.

One thing I could not verify myself: the Linux and macOS expectations for the three new in-place renames. I mirrored the existing patterns — the file case follows test_file_lifecyle and the directory case follows test_renaming_top_level_directory — but I have no macOS machine, so if FSEvents reports a case-only rename differently that is where it will show up. Happy to adjust once CI has run.

Review feedback: rather than a separate Windows-only test, cover every
case-change variant in test_case_change() and add a subfolder rename.

The cross-directory move that test_case_change() already performed is
kept exactly as it was, including its Windows branch, because Windows
reports that operation with no rename records at all. Three in-place
renames are added after it: a file, a directory, and a subfolder one
level down.

Reverting winapi.py to master now fails this test on the in-place file
rename with a FileDeletedEvent alongside the FileMovedEvent, so the
coalescing is what the test pins.
@BoboTiG
BoboTiG merged commit 2a0d0ce into gorakhargosh:master Aug 11, 2026
29 of 30 checks passed
@BoboTiG

BoboTiG commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Thanks @MohammedAlkindi 🥂

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Windows] Generate a FileMovedEvent when changing the case of a file

2 participants