Description
On macOS with the default case-insensitive APFS filesystem, renaming a file with only a case change (e.g., hello.txt → Hello.txt) produces two added events instead of the expected deleted(old) + added(new) pair.
This happens because the fallback rename handling in src/lib.rs uses Path::new(&path).exists() to determine whether a rename event corresponds to the source or destination. On a case-insensitive filesystem, both hello.txt and Hello.txt resolve to the same inode, so exists() returns true for both paths, causing both to be classified as added.
Example Code
import os
import tempfile
import time
from pathlib import Path
from threading import Thread
from watchfiles import watch, Change
def main():
with tempfile.TemporaryDirectory() as tmp_dir:
watch_dir = Path(tmp_dir)
original = watch_dir / "hello.txt"
renamed = 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
if __name__ == "__main__":
main()
Watchfiles Output
{(Change.added, '/tmp/xxx/Hello.txt'), (Change.added, '/tmp/xxx/hello.txt')}
{(Change.added, '/tmp/xxx/stop.txt')}
Expected Output
{(Change.deleted, '/tmp/xxx/hello.txt'), (Change.added, '/tmp/xxx/Hello.txt')}
{(Change.added, '/tmp/xxx/stop.txt')}
Operating System & Architecture
macOS-26.4.1-arm64-arm-64bit
Darwin Kernel Version 25.4.0: Thu Mar 19 19:31:09 PDT 2026; root:xnu-12377.101.15~1/RELEASE_ARM64_T8132
Environment
No response
Python & Watchfiles Version
python: 3.13.13 (main, Apr 14 2026, 14:32:41) [Clang 22.1.3 ], watchfiles: 1.1.1
Rust & Cargo Version
No response
Root Cause
In src/lib.rs L154-162:
EventKind::Modify(ModifyKind::Name(_)) => {
if Path::new(&path).exists() {
CHANGE_ADDED
} else {
CHANGE_DELETED
}
}
On case-insensitive filesystems, Path::new("hello.txt").exists() returns true even after the file has been renamed to Hello.txt, because the OS treats them as the same path. Both events get classified as CHANGE_ADDED.
A possible fix would be to compare the actual on-disk filename (e.g., via std::fs::canonicalize or reading the directory entry) against the event path to determine if the casing matches.
Description
On macOS with the default case-insensitive APFS filesystem, renaming a file with only a case change (e.g.,
hello.txt→Hello.txt) produces twoaddedevents instead of the expecteddeleted(old)+added(new)pair.This happens because the fallback rename handling in
src/lib.rsusesPath::new(&path).exists()to determine whether a rename event corresponds to the source or destination. On a case-insensitive filesystem, bothhello.txtandHello.txtresolve to the same inode, soexists()returnstruefor both paths, causing both to be classified asadded.Example Code
Watchfiles Output
Expected Output
Operating System & Architecture
macOS-26.4.1-arm64-arm-64bit
Darwin Kernel Version 25.4.0: Thu Mar 19 19:31:09 PDT 2026; root:xnu-12377.101.15~1/RELEASE_ARM64_T8132
Environment
No response
Python & Watchfiles Version
python: 3.13.13 (main, Apr 14 2026, 14:32:41) [Clang 22.1.3 ], watchfiles: 1.1.1
Rust & Cargo Version
No response
Root Cause
In
src/lib.rsL154-162:On case-insensitive filesystems,
Path::new("hello.txt").exists()returnstrueeven after the file has been renamed toHello.txt, because the OS treats them as the same path. Both events get classified asCHANGE_ADDED.A possible fix would be to compare the actual on-disk filename (e.g., via
std::fs::canonicalizeor reading the directory entry) against the event path to determine if the casing matches.