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
36 changes: 35 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,33 @@ struct RustNotify {
watcher: WatcherEnum,
}

/// Case-sensitive check that the given path's file name exists in its parent directory.
///
/// `Path::exists()` resolves names through the filesystem, which is case-insensitive on
/// the default macOS APFS and on Windows NTFS. As a result it can return `true` for a
/// path whose exact-case filename does not exist on disk -- only a case-folded sibling
/// does. This helper avoids that ambiguity by reading the parent directory and looking
/// for an entry whose file name matches `path`'s file name byte-for-byte. On
/// case-sensitive filesystems it is equivalent to `Path::exists()`.
fn path_exists_exact_case(path: &Path) -> bool {
let (parent, file_name) = match (path.parent(), path.file_name()) {
(Some(p), Some(n)) => (p, n),
// Path has no parent (e.g. "/") or no file name component: fall back to exists().
_ => return path.exists(),
};
// An empty parent (e.g. relative bare filename like "hello.txt") means the current
// directory; pass it through as "." so read_dir succeeds.
let parent = if parent.as_os_str().is_empty() {
Path::new(".")
} else {
parent
};
match std::fs::read_dir(parent) {
Ok(entries) => entries.filter_map(|e| e.ok()).any(|e| e.file_name() == file_name),
Err(_) => false,
}
}

fn map_watch_error(error: notify::Error) -> PyErr {
let err_string = error.to_string();
match error.kind {
Expand Down Expand Up @@ -153,7 +180,14 @@ impl RustNotify {
// On macOS the modify name event is triggered when a file is renamed,
// but no information about whether it's the src or dst path is available.
// Hence we have to check if the file exists instead.
if Path::new(&path).exists() {
//
// `Path::exists()` is case-insensitive on case-insensitive filesystems
// (e.g. the default APFS on macOS), so for a case-only rename like
// `hello.txt` -> `Hello.txt` it would report both endpoints as existing
// and emit two ADDED events instead of a DELETED/ADDED pair (#368).
// `path_exists_exact_case` checks the parent directory for an entry
// matching `path`'s file name byte-for-byte, distinguishing the two.
if path_exists_exact_case(Path::new(&path)) {
CHANGE_ADDED
} else {
CHANGE_DELETED
Expand Down
1 change: 1 addition & 0 deletions tests/test_files/case_rename.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
case rename fixture for issue #368
35 changes: 35 additions & 0 deletions tests/test_rust_notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,41 @@ def test_rename(test_dir: Path):
}


@pytest.mark.skipif(sys.platform != 'darwin', reason='macOS-specific case-insensitive APFS behaviour')
def test_rename_case_only(test_dir: Path):
"""Regression test for https://github.com/samuelcolvin/watchfiles/issues/368.

On the default case-insensitive APFS the rename emits two ``Modify(Name(Any))``
events whose paths only differ in case, both pointing at a file that ``Path::exists``
treats as present. Previously both events were misclassified as ADDED instead of a
DELETED/ADDED pair.
"""
src = test_dir / 'case_rename.txt'
if not src.exists():
pytest.skip('case_rename.txt fixture missing')

# Skip if test_dir happens to live on a case-sensitive filesystem (e.g. APFS configured
# case-sensitive, or a UFS volume). The bug only manifests when the FS case-folds at
# lookup time.
if not (test_dir / 'CASE_RENAME.TXT').exists():
pytest.skip('test_dir is on a case-sensitive filesystem')

dst = test_dir / 'CASE_RENAME.txt'
watcher = RustNotify([str(test_dir)], False, False, 0, True, False)
try:
src.rename(dst)

assert watcher.watch(200, 50, 500, None) == {
(3, str(src)),
(1, str(dst)),
}
finally:
# Restore so the fixture's session-level snapshot stays consistent if this test
# runs before other tests that scan test_dir.
if dst.exists():
dst.rename(src)


def test_watch_multiple(tmp_path: Path):
foo = tmp_path / 'foo'
foo.mkdir()
Expand Down
Loading