.tasks/core/WATCH-002-platform-rename-detection.md
Implement platform-specific rename detection to handle the fact that different operating systems provide varying levels of rename event support. macOS FSEvents doesn't provide native rename tracking, so we implement inode-based detection. Linux inotify provides better support, and Windows ReadDirectoryChangesW provides reasonable tracking.
When a file is renamed, different platforms behave differently:
| Platform | Native Rename Support | Fallback Needed |
|---|---|---|
| macOS FSEvents | ❌ No (emits separate create/delete) | ✅ Inode tracking |
| Linux inotify | ✅ Yes (MOVED_FROM/MOVED_TO) | ⚠️ Buffer for stability |
| Windows | ⚠️ Partial (rename provided but needs buffering) | ✅ Buffer matching |
Without rename detection, moving file.txt → renamed.txt would appear as:
file.txtrenamed.txtThis breaks downstream logic that tracks files by UUID - a rename shouldn't create a new entry.
macOS FSEvents emits separate create/delete events for renames. We detect renames by tracking inodes:
struct MacOSRenameDetector {
// Maps inode → (path, timestamp) for recently deleted files
deleted_inodes: HashMap<u64, (PathBuf, SystemTime)>,
// Cleanup timer
cleanup_interval: Duration, // 500ms
}
impl MacOSRenameDetector {
async fn handle_create(&mut self, path: PathBuf, inode: u64) -> Option<FsEvent> {
// Check if this inode was recently deleted
if let Some((old_path, _)) = self.deleted_inodes.remove(&inode) {
// Same inode created within 500ms = rename!
return Some(FsEvent {
path: path.clone(),
kind: FsEventKind::Rename {
from: old_path,
to: path,
},
timestamp: SystemTime::now(),
is_directory: None,
});
}
// Not a rename, just a create
Some(FsEvent {
path,
kind: FsEventKind::Create,
timestamp: SystemTime::now(),
is_directory: None,
})
}
async fn handle_delete(&mut self, path: PathBuf, inode: u64) {
// Buffer delete for 500ms
self.deleted_inodes.insert(inode, (path, SystemTime::now()));
// After 500ms, if no matching create, emit actual delete
}
async fn cleanup_expired(&mut self) -> Vec<FsEvent> {
let now = SystemTime::now();
let mut expired = Vec::new();
self.deleted_inodes.retain(|_, (path, timestamp)| {
if now.duration_since(*timestamp).unwrap() > self.cleanup_interval {
// No matching create arrived, emit delete
expired.push(FsEvent {
path: path.clone(),
kind: FsEventKind::Remove,
timestamp: *timestamp,
is_directory: None,
});
false // Remove from map
} else {
true // Keep buffering
}
});
expired
}
}
Flow:
Linux inotify provides MOVED_FROM and MOVED_TO events with a cookie linking them:
struct LinuxRenameDetector {
// Maps cookie → old_path for pending moves
pending_moves: HashMap<u32, PathBuf>,
}
impl LinuxRenameDetector {
async fn handle_moved_from(&mut self, path: PathBuf, cookie: u32) {
// Buffer old path with cookie
self.pending_moves.insert(cookie, path);
}
async fn handle_moved_to(&mut self, path: PathBuf, cookie: u32) -> FsEvent {
if let Some(old_path) = self.pending_moves.remove(&cookie) {
// Matching cookie = rename
FsEvent {
path: path.clone(),
kind: FsEventKind::Rename {
from: old_path,
to: path,
},
timestamp: SystemTime::now(),
is_directory: None,
}
} else {
// No matching cookie, treat as create
FsEvent {
path,
kind: FsEventKind::Create,
timestamp: SystemTime::now(),
is_directory: None,
}
}
}
}
Windows ReadDirectoryChangesW provides rename information but needs buffering for reliability:
struct WindowsRenameDetector {
// Buffer remove events briefly to match with creates
removed_paths: HashMap<PathBuf, SystemTime>,
}
impl WindowsRenameDetector {
async fn handle_remove(&mut self, path: PathBuf) {
self.removed_paths.insert(path, SystemTime::now());
}
async fn handle_create(&mut self, path: PathBuf) -> FsEvent {
// Check if similar path was removed recently (fuzzy match)
// Windows rename detection is less precise, so we do best-effort
// Based on file extension and parent directory matching
for (removed_path, timestamp) in &self.removed_paths {
if paths_likely_same_file(&path, removed_path) {
return FsEvent {
path: path.clone(),
kind: FsEventKind::Rename {
from: removed_path.clone(),
to: path,
},
timestamp: SystemTime::now(),
is_directory: None,
};
}
}
// No match, just a create
FsEvent {
path,
kind: FsEventKind::Create,
timestamp: SystemTime::now(),
is_directory: None,
}
}
}
crates/fs-watcher/src/platform/macos.rs - macOS inode-based rename detectioncrates/fs-watcher/src/platform/linux.rs - Linux inotify rename handlingcrates/fs-watcher/src/platform/windows.rs - Windows rename bufferingcrates/fs-watcher/src/platform/mod.rs - Platform selectionPer-platform tests located in crates/fs-watcher/src/platform/:
test_macos_inode_rename_detection - Verify inode trackingtest_macos_expired_delete - Verify cleanup timertest_linux_cookie_matching - Verify cookie-based matchingtest_windows_buffered_rename - Verify buffered detectionLocated in crates/fs-watcher/tests/:
test_rename_detection_macos - Full rename flow on macOStest_rename_detection_linux - Full rename flow on Linuxtest_rename_detection_windows - Full rename flow on Windowstest_rapid_renames - Multiple quick renamestest_cross_directory_rename - Rename across directories# macOS
touch /tmp/test.txt
# Wait for watcher to register
mv /tmp/test.txt /tmp/renamed.txt
# Should emit: Rename { from: "/tmp/test.txt", to: "/tmp/renamed.txt" }
# Linux
touch /tmp/test.txt
mv /tmp/test.txt /tmp/renamed.txt
# Should emit: Rename (native inotify support)
# Windows
echo "test" > C:\temp\test.txt
rename C:\temp\test.txt renamed.txt
# Should emit: Rename (buffered detection)
| Platform | Rename Detection Time | Memory Overhead | False Positive Rate |
|---|---|---|---|
| macOS | ~500ms buffer | HashMap of recent deletes | Very low (<0.1%) |
| Linux | Immediate | HashMap of pending moves | Negligible |
| Windows | ~100ms buffer | HashMap of recent removes | Low (~1%) |
Trade-off: Small latency (buffering) for accurate rename detection.
For even better macOS rename detection, the PersistentIndexService can maintain an inode cache:
// When Remove event received on macOS:
async fn handle_remove_with_db_lookup(path: PathBuf, inode: u64) -> FsEvent {
// Check if inode exists in database
if let Some(entry) = db.find_entry_by_inode(inode).await? {
// This inode is known, might be a rename
// Buffer it and wait for potential create
buffer_for_rename_detection(path, inode, entry.id).await;
} else {
// Unknown inode, just a delete
emit_remove_event(path).await;
}
}
This is implemented in the PersistentIndexService, not in this crate (fs-watcher remains storage-agnostic).