Skip to content

Fix case-only rename on macOS APFS misclassified as two added events#381

Open
mokashang wants to merge 1 commit into
samuelcolvin:mainfrom
mokashang:fix/macos-case-rename-368
Open

Fix case-only rename on macOS APFS misclassified as two added events#381
mokashang wants to merge 1 commit into
samuelcolvin:mainfrom
mokashang:fix/macos-case-rename-368

Conversation

@mokashang

Copy link
Copy Markdown

Fixes #368.

On the default case-insensitive APFS filesystem on macOS, renaming a file with only a case change — e.g. hello.txtHello.txt — produced two Change.added events instead of the expected Change.deleted(old) + Change.added(new) pair.

The macOS notify backend emits two Modify(Name(Any)) events for the rename, one per path, with no source/destination hint. The fallback in src/lib.rs resolved this by calling Path::exists on the event path: the one that exists is the new path (ADDED), the one that doesn't is the old path (DELETED). On a case-insensitive filesystem exists() returns true for both hello.txt and Hello.txt (they resolve to the same inode), so both events were misclassified as ADDED.

Repro (matches the issue)

import os, tempfile, time
from pathlib import Path
from threading import Thread
from watchfiles import watch

with tempfile.TemporaryDirectory() as tmp_dir:
    watch_dir = Path(tmp_dir)
    original, renamed = watch_dir / 'hello.txt', watch_dir / 'Hello.txt'
    original.write_text('content')
    time.sleep(0.5)

    def do_rename():
        time.sleep(1.5)
        os.rename(original, renamed)
        time.sleep(3)
        (watch_dir / 'stop.txt').write_text('stop')

    Thread(target=do_rename, daemon=True).start()
    for changes in watch(watch_dir, debounce=400, step=100):
        print(changes)
        if any(Path(p).name == 'stop.txt' for _, p in changes):
            break

Before:

{(<Change.added: 1>, '.../hello.txt'), (<Change.added: 1>, '.../Hello.txt')}

After:

{(<Change.deleted: 3>, '.../hello.txt'), (<Change.added: 1>, '.../Hello.txt')}

Approach

Add a small path_exists_exact_case helper that reads the parent directory and looks for an entry whose OsStr file name matches the event path's file name byte-for-byte, and use it in place of Path::exists for the ModifyKind::Name(_) branch. On case-sensitive filesystems the behaviour is unchanged (the directory entry's name matches exactly when the file exists, and read_dir finds nothing when it doesn't). On case-insensitive filesystems the rename's old path no longer matches any directory entry, so it is correctly classified as DELETED.

I considered using std::fs::canonicalize + filename comparison instead, but that follows symlinks and is happy to return an unexpected path when symlinks are in play; read_dir keeps the check local to the actual parent directory.

Tests

  • New test_rename_case_only in tests/test_rust_notify.py (darwin-only, with an inner skip when the FS happens to be case-sensitive) that renames case_rename.txtCASE_RENAME.txt and asserts the DELETED/ADDED pair, then renames back so other tests are unaffected.
  • New fixture file tests/test_files/case_rename.txt.
  • The existing test_rename continues to pass: it renames a.txta.new, so both the source's exact-case entry disappears and the destination's exact-case entry appears, and both branches still resolve as before.

Verified locally on macOS 25.5 (APFS, case-insensitive): the new test fails on main and passes with this change. cargo clippy -- -D warnings, cargo fmt --check, ruff check, ruff format --check, and mypy are all clean.

On the default case-insensitive APFS filesystem, renaming a file with
only a case change (e.g. 'hello.txt' -> 'Hello.txt') produced two
ADDED events instead of the expected DELETED + ADDED pair.

The macOS notify backend emits two 'Modify(Name(Any))' events for the
rename, one for each path, with no source/destination hint. The fallback
in src/lib.rs used 'Path::exists' to decide which event was the new
path: since APFS resolves both 'hello.txt' and 'Hello.txt' to the same
inode, that check returned true for both events and both ended up
classified as ADDED.

Replace 'Path::exists' with a 'path_exists_exact_case' helper that
reads the parent directory and looks for an entry whose file name
matches the event path byte-for-byte. On case-sensitive filesystems
the behaviour is unchanged.

Adds a darwin-only regression test gated on the FS actually being
case-insensitive, plus a small fixture file the test renames.

Fixes samuelcolvin#368
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.

macOS: case-only rename produces spurious added event instead of deleted + added pair

1 participant