Repository: fsnotify/fsnotify
Pull request #696 (2025-05-20 17:11:57)

inotify: send Rename event if recursive watch is renamed
This mimics what happens with a non-recursive watch.


Patch:
diff --git a/backend_inotify.go b/backend_inotify.go
index d6c2860..bd18597 100644
--- a/backend_inotify.go
+++ b/backend_inotify.go
@@ -55,10 +55,10 @@ type (
 		path map[string]uint32 // pathname → wd
 	}
 	watch struct {
-		wd      uint32 // Watch descriptor (as returned by the inotify_add_watch() syscall)
-		flags   uint32 // inotify flags of this watch (see inotify(7) for the list of valid flags)
-		path    string // Watch path.
-		recurse bool   // Recursion with ./...?
+		wd         uint32 // Watch descriptor (as returned by the inotify_add_watch() syscall)
+		flags      uint32 // inotify flags of this watch (see inotify(7) for the list of valid flags)
+		path       string // Watch path.
+		watchFlags watchFlag
 	}
 	koekje struct {
 		cookie uint32
@@ -66,6 +66,9 @@ type (
 	}
 )
 
+func (w watch) byUser() bool  { return w.watchFlags&flagByUser != 0 }
+func (w watch) recurse() bool { return w.watchFlags&flagRecurse != 0 }
+
 func newWatches() *watches {
 	return &watches{
 		wd:   make(map[uint32]*watch),
@@ -87,13 +90,13 @@ func (w *watches) removePath(path string) ([]uint32, error) {
 	}
 
 	watch := w.wd[wd]
-	if recurse && !watch.recurse {
+	if recurse && !watch.recurse() {
 		return nil, fmt.Errorf("can't use /... with non-recursive watch %q", path)
 	}
 
 	delete(w.path, path)
 	delete(w.wd, wd)
-	if !watch.recurse {
+	if !watch.recurse() {
 		return []uint32{wd}, nil
 	}
 
@@ -188,7 +191,7 @@ func (w *inotify) AddWith(path string, opts ...addOpt) error {
 		return fmt.Errorf("%w: %s", xErrUnsupported, with.op)
 	}
 
-	add := func(path string, with withOpts, recurse bool) error {
+	add := func(path string, with withOpts, wf watchFlag) error {
 		var flags uint32
 		if with.noFollow {
 			flags |= unix.IN_DONT_FOLLOW
@@ -220,7 +223,7 @@ func (w *inotify) AddWith(path string, opts ...addOpt) error {
 		if with.op.Has(xUnportableCloseRead) {
 			flags |= unix.IN_CLOSE_NOWRITE
 		}
-		return w.register(path, flags, recurse)
+		return w.register(path, flags, wf)
 	}
 
 	w.mu.Lock()
@@ -248,14 +251,18 @@ func (w *inotify) AddWith(path string, opts ...addOpt) error {
 				w.sendEvent(Event{Name: root, Op: Create})
 			}
 
-			return add(root, with, true)
+			wf := flagRecurse
+			if root == path {
+				wf |= flagByUser
+			}
+			return add(root, with, wf)
 		})
 	}
 
-	return add(path, with, false)
+	return add(path, with, 0)
 }
 
-func (w *inotify) register(path string, flags uint32, recurse bool) error {
+func (w *inotify) register(path string, flags uint32, wf watchFlag) error {
 	return w.watches.updatePath(path, func(existing *watch) (*watch, error) {
 		if existing != nil {
 			flags |= existing.flags | unix.IN_MASK_ADD
@@ -272,10 +279,10 @@ func (w *inotify) register(path string, flags uint32, recurse bool) error {
 
 		if existing == nil {
 			return &watch{
-				wd:      uint32(wd),
-				path:    path,
-				flags:   flags,
-				recurse: recurse,
+				wd:         uint32(wd),
+				path:       path,
+				flags:      flags,
+				watchFlags: wf,
 			}, nil
 		}
 
@@ -450,7 +457,9 @@ func (w *inotify) handleEvent(inEvent *unix.InotifyEvent, buf *[65536]byte, offs
 	// We can't really update the state when a watched path is moved; only
 	// IN_MOVE_SELF is sent and not IN_MOVED_{FROM,TO}. So remove the watch.
 	if inEvent.Mask&unix.IN_MOVE_SELF == unix.IN_MOVE_SELF {
-		if watch.recurse { // Do nothing
+		// Watch is set up as part of recurse: do nothing as the move gets
+		// registered from the parent directory.
+		if watch.recurse() && !watch.byUser() {
 			return Event{}, true
 		}
 
@@ -460,6 +469,10 @@ func (w *inotify) handleEvent(inEvent *unix.InotifyEvent, buf *[65536]byte, offs
 				return Event{}, false
 			}
 		}
+
+		if watch.recurse() {
+			return Event{Name: watch.path, Op: Rename}, true
+		}
 	}
 
 	/// Skip if we're watching both this path and the parent; the parent will
@@ -473,11 +486,11 @@ func (w *inotify) handleEvent(inEvent *unix.InotifyEvent, buf *[65536]byte, offs
 
 	ev := w.newEvent(name, inEvent.Mask, inEvent.Cookie)
 	// Need to update watch path for recurse.
-	if watch.recurse {
+	if watch.recurse() {
 		isDir := inEvent.Mask&unix.IN_ISDIR == unix.IN_ISDIR
 		/// New directory created: set up watch on it.
 		if isDir && ev.Has(Create) {
-			err := w.register(ev.Name, watch.flags, true)
+			err := w.register(ev.Name, watch.flags, flagRecurse)
 			if !w.sendError(err) {
 				return Event{}, false
 			}
@@ -507,14 +520,6 @@ func (w *inotify) handleEvent(inEvent *unix.InotifyEvent, buf *[65536]byte, offs
 	return ev, true
 }
 
-func (w *inotify) isRecursive(path string) bool {
-	ww := w.watches.byPath(path)
-	if ww == nil { // path could be a file, so also check the Dir.
-		ww = w.watches.byPath(filepath.Dir(path))
-	}
-	return ww != nil && ww.recurse
-}
-
 func (w *inotify) newEvent(name string, mask, cookie uint32) Event {
 	e := Event{Name: name}
 	if mask&unix.IN_CREATE == unix.IN_CREATE || mask&unix.IN_MOVED_TO == unix.IN_MOVED_TO {
@@ -578,6 +583,6 @@ func (w *inotify) state() {
 	w.mu.Lock()
 	defer w.mu.Unlock()
 	for wd, ww := range w.watches.wd {
-		fmt.Fprintf(os.Stderr, "%4d: %q  recurse=%t\n", wd, ww.path, ww.recurse)
+		fmt.Fprintf(os.Stderr, "%4d: %q  watchFlags=0x%x\n", wd, ww.path, ww.watchFlags)
 	}
 }
diff --git a/fsnotify.go b/fsnotify.go
index dc5364c..248099e 100644
--- a/fsnotify.go
+++ b/fsnotify.go
@@ -494,3 +494,13 @@ func recursivePath(path string) (string, bool) {
 	}
 	return path, false
 }
+
+type watchFlag uint8
+
+const (
+	// Added by user with Add(), rather than an internal watch.
+	flagByUser = watchFlag(0x01)
+	// Part of recursive watch; as the top-level path added by the user or an
+	// "internal" watch.
+	flagRecurse = watchFlag(0x02)
+)


Test patch:
diff --git a/testdata/watch-dir/create-fifo b/testdata/watch-dir/create-fifo
index 10efeb4..a29cdd3 100644
--- a/testdata/watch-dir/create-fifo
+++ b/testdata/watch-dir/create-fifo
@@ -1,5 +1,3 @@
-# FIFO
-
 require mkfifo
 
 watch  /
diff --git a/testdata/watch-dir/dir-only b/testdata/watch-dir/dir-only
index 6d7d88f..b445240 100644
--- a/testdata/watch-dir/dir-only
+++ b/testdata/watch-dir/dir-only
@@ -1,5 +1,3 @@
-#
-
 touch /before-watch
 watch /
 
diff --git a/testdata/watch-dir/only-chmod b/testdata/watch-dir/only-chmod
index f108505..d9acaf3 100644
--- a/testdata/watch-dir/only-chmod
+++ b/testdata/watch-dir/only-chmod
@@ -13,11 +13,11 @@ echo data >>/link
 touch /dir/file
 
 # Rename
-mv /file /rename
+mv /file   /rename
 mv /rename /file
-mv /dir /rename
+mv /dir    /rename
 mv /rename /dir
-mv /link /rename
+mv /link   /rename
 mv /rename /link
 
 # Chmod
diff --git a/testdata/watch-dir/rename-nested-watched-dir b/testdata/watch-dir/rename-nested-watched-dir
new file mode 100644
index 0000000..ae61db1
--- /dev/null
+++ b/testdata/watch-dir/rename-nested-watched-dir
@@ -0,0 +1,24 @@
+# Looks like Windows doesn't allow rename if there's watch:
+# mv("/001/dir", "/001/dir-rename"): rename /001/dir /001/dir-rename: Access is denied.
+skip windows
+
+mkdir -p /dir/sub
+
+watch /dir
+watch /dir/sub
+
+mv /dir /dir-rename
+touch /dir-rename/sub/file
+
+Output:
+	rename   /dir
+	# Still /dir as it's a different watch, which doesn't get updated. Could be
+	# considered both a feature and a bug (see #694).
+	create   /dir/sub/file
+
+	kqueue: # TODO: kqueue only sends rename.
+		rename /dir
+
+	illumos:  # TODO
+		rename   /dir
+		remove   /dir/sub
diff --git a/testdata/watch-dir/rename-overwrite b/testdata/watch-dir/rename-overwrite
index 0e6a848..9680164 100644
--- a/testdata/watch-dir/rename-overwrite
+++ b/testdata/watch-dir/rename-overwrite
@@ -6,16 +6,19 @@ touch /rename
 watch /
 
 mv /file /rename
+echo foo >>/rename
 
 Output:
 	remove  /rename
 	rename  /file
 	create  /rename ← /file
+	write   /rename
 
 	# Inotify just sends MOVED_FROM and MOVED_TO.
 	linux:
 		rename /file
 		create /rename ← /file
+		write  /rename
 
 	dragonfly:
 		remove /
diff --git a/testdata/watch-dir/rename-to-unwatched b/testdata/watch-dir/rename-to-unwatched
index 9adb031..4f644cd 100644
--- a/testdata/watch-dir/rename-to-unwatched
+++ b/testdata/watch-dir/rename-to-unwatched
@@ -1,9 +1,5 @@
 # Rename to unwatched dir.
 
-# if runtime.GOOS == "netbsd" && isCI() {
-# 	t.Skip("fails in CI; see #488") // TODO
-# }
-
 mkdir /dir
 mkdir /unwatch
 watch /dir
diff --git a/testdata/watch-dir/subdir b/testdata/watch-dir/subdir
index f1544eb..e8a6cd1 100644
--- a/testdata/watch-dir/subdir
+++ b/testdata/watch-dir/subdir
@@ -1,5 +1,3 @@
-# 
-
 watch /
 
 mkdir /sub       # Create sub-directory
diff --git a/testdata/watch-dir/symlink-dir b/testdata/watch-dir/symlink-dir
index f8b23a3..d32bfca 100644
--- a/testdata/watch-dir/symlink-dir
+++ b/testdata/watch-dir/symlink-dir
@@ -4,9 +4,7 @@ require symlink
 mkdir /dir
 watch /
 ln -s /dir /link
-
-# Shouldn't give any events.
-touch link/file
+touch link/file  # Shouldn't give any events.
 
 Output:
 	create  /link
diff --git a/testdata/watch-dir/truncate-file b/testdata/watch-dir/truncate-file
index 73cd9bc..c390329 100644
--- a/testdata/watch-dir/truncate-file
+++ b/testdata/watch-dir/truncate-file
@@ -9,9 +9,9 @@ Output:
 	write  /file  # write
 
 	# Truncate is chmod on kqueue, except dragonfly where it seems a write.
+	kqueue:
+		chmod  /file
+		write  /file
 	dragonfly:
 		write  /file
 		write  /file
-	kqueue:
-		chmod     /file
-		write     /file
diff --git a/testdata/watch-file/chmod b/testdata/watch-file/chmod
index 95cb392..bee71de 100644
--- a/testdata/watch-file/chmod
+++ b/testdata/watch-file/chmod
@@ -7,5 +7,5 @@ chmod 700 /file
 Output:
 	chmod /file
 
-	windows:
+	windows:  # No chmod on Windows
 		no-events
diff --git a/testdata/watch-file/chmod-after-write b/testdata/watch-file/chmod-after-write
index 4355a61..0e24cf4 100644
--- a/testdata/watch-file/chmod-after-write
+++ b/testdata/watch-file/chmod-after-write
@@ -13,5 +13,5 @@ Output:
 	write  /file
 	chmod  /file
 
-	windows:
+	windows:  # No chmod on Windows
 		write /file
diff --git a/testdata/watch-file/re-add-renamed-filed b/testdata/watch-file/re-add-renamed-file
similarity index 100%
rename from testdata/watch-file/re-add-renamed-filed
rename to testdata/watch-file/re-add-renamed-file
diff --git a/testdata/watch-file/remove-watched-file b/testdata/watch-file/remove-watched-file
index aee4e4a..7f01a31 100644
--- a/testdata/watch-file/remove-watched-file
+++ b/testdata/watch-file/remove-watched-file
@@ -7,6 +7,6 @@ rm /file
 Output:
 	remove /file
 
-	linux:  # unlink always emits a chmod on linux.
+	linux:  # unlink always emits a chmod on Linux.
 		chmod  /file
 		remove /file
diff --git a/testdata/watch-recurse/rename-dir b/testdata/watch-recurse/rename-dir
index 3132a20..e446972 100644
--- a/testdata/watch-recurse/rename-dir
+++ b/testdata/watch-recurse/rename-dir
@@ -15,7 +15,6 @@ Output:
 	create     /sub-rename/file      # touch /sub-rename/file
 	create     /sub-rename/dir/file  # touch /sub-rename/dir/file
 
-
 	# Same as above, but with these stupid dir writes Windows sends.
 	#
 	# TODO: see if we can suppress that.
diff --git a/testdata/watch-recurse/rename-to-different-dir b/testdata/watch-recurse/rename-to-different-dir
new file mode 100644
index 0000000..f50ad69
--- /dev/null
+++ b/testdata/watch-recurse/rename-to-different-dir
@@ -0,0 +1,23 @@
+# TODO: frequently (but not always) hangs in the CI until it gets killed. Not
+# sure what's going on – can't reproduce locally.
+skip windows
+
+# Make sure RenamedFrom is set when renaming from one directory to another.
+require recurse
+
+mkdir /dir1
+mkdir /dir2
+touch /dir1/file
+
+watch /...
+
+mv /dir1/file /dir2/rename
+
+Output:
+	rename   /dir1/file
+	create   /dir2/rename ← /dir1/file
+
+	windows:  # TODO: Windows sends these stupid writes
+		remove   /dir1/file
+		create   /dir2/rename
+		write    /dir2
diff --git a/testdata/watch-recurse/rename-watched-dir b/testdata/watch-recurse/rename-watched-dir
new file mode 100644
index 0000000..ce68efa
--- /dev/null
+++ b/testdata/watch-recurse/rename-watched-dir
@@ -0,0 +1,15 @@
+require recurse
+
+mkdir /dir
+mkdir /dir/sub
+
+watch /dir/...
+
+mv /dir /renamed
+touch /renamed/sub/file
+
+Output:
+	rename   /dir
+
+	windows:  # TODO
+		create  /dir/sub/file