Repository: fsnotify/fsnotify
Pull request #628 (2024-04-28 01:58:12)

inotify, windows: track renames
Add Event.RenamedFrom to track event renames; this is sent on a subsequent Create event; for example:

	Event{Op: Rename, Name: "/tmp/file"}
	Event{Op: Create, Name: "/tmp/renamed", RenamedFrom: "/tmp/file"}

For now this is unexported as renamedFrom because the kqueue and FEN backends are not yet implemented.

Updates #26

arp242: Do you know how to do with this illumos @nshalman? I spent some time looking at it, but I couldn't really figure out how to best do this.

Unless I missed something, there isn't really any support to track "file A got renamed to file B"?

Otherwise we can compare directory snapshots like with kqueue (still need to write this) I guess.
nshalman: > Do you know how to do with this illumos @nshalman? I spent some time looking at it, but I couldn't really figure out how to best do this.
> 
> Unless I missed something, there isn't really any support to track "file A got renamed to file B"?
> 
> Otherwise we can compare directory snapshots like with kqueue (still need to write this) I guess.

This should certainly be possible somehow. https://illumos.org/man/3C/port_associate includes the text:

>        The following exception events are delivered when they occur. These event
>        types cannot be filtered.
> 
>          FILE_DELETE       /* Monitored file/directory was deleted */
>          FILE_RENAME_TO    /* Monitored file/directory was renamed */
>          FILE_RENAME_FROM  /* Monitored file/directory was renamed */
>          UNMOUNTED         /* Monitored file system got unmounted */
>          MOUNTEDOVER       /* Monitored file/directory was mounted over */

And we certainly have code handling these events... https://github.com/fsnotify/fsnotify/blob/main/backend_fen.go#L450-L470

I don't know what changes we would need. I'd have to take some time to look how you did this for inotify to see if I can figure out how to do something similar here. I imagine if we need to track additional information we might want to stash some other information in the cookie when we do our associations.

I'll try to find some time to look at this, but it will probably not be for at least a week.

Thanks for keeping me in the loop!
arp242: I unexported `RenamedFrom` to `renamedFrom` for now, and the tests aren't run on platforms where this isn't supported yet (kq, fen). This way new features like this can be developed and merged in to main without holding up everything, and PRs can be a bit smaller in scope (I did some work on kqueue, which required rewriting significant bits of it).

To enable running the rename tests, add new platforms to `supportsRename()` in helpers_test.go over here: https://github.com/fsnotify/fsnotify/pull/628/files#diff-62f46d82044a129997b958c0f7eb6a95038395d337176418d3dffae98d9332d1R636

Patch:
diff --git a/backend_inotify.go b/backend_inotify.go
index 2f0d0a0..2937197 100644
--- a/backend_inotify.go
+++ b/backend_inotify.go
@@ -136,6 +136,24 @@ type Watcher struct {
 	done        chan struct{} // Channel for sending a "quit message" to the reader goroutine
 	doneMu      sync.Mutex
 	doneResp    chan struct{} // Channel to respond to Close
+
+	// Store rename cookies in an array, with the index wrapping to 0. Almost
+	// all of the time what we get is a MOVED_FROM to set the cookie and the
+	// next event inotify sends will be MOVED_TO to read it. However, this is
+	// not guaranteed – as described in inotify(7) – and we may get other events
+	// between the two MOVED_* events (including other MOVED_* ones).
+	//
+	// A second issue is that moving a file outside the watched directory will
+	// trigger a MOVED_FROM to set the cookie, but we never see the MOVED_TO to
+	// read and delete it. So just storing it in a map would slowly leak memory.
+	//
+	// Doing it like this gives us a simple fast LRU-cache that won't allocate.
+	// Ten items should be more than enough for our purpose, and a loop over
+	// such a short array is faster than a map access anyway (not that it hugely
+	// matters since we're talking about hundreds of ns at the most, but still).
+	cookies     [10]koekje
+	cookieIndex uint8
+	cookiesMu   sync.Mutex
 }
 
 type (
@@ -149,6 +167,10 @@ type (
 		flags uint32 // inotify flags of this watch (see inotify(7) for the list of valid flags)
 		path  string // Watch path.
 	}
+	koekje struct {
+		cookie uint32
+		path   string
+	}
 )
 
 func newWatches() *watches {
@@ -547,7 +569,7 @@ func (w *Watcher) readEvents() {
 			}
 
 			if debug {
-				internal.Debug(name, raw.Mask)
+				internal.Debug(name, raw.Mask, raw.Cookie)
 			}
 
 			// inotify will automatically remove the watch on deletes; just need
@@ -579,7 +601,7 @@ func (w *Watcher) readEvents() {
 
 			/// Send the events that are not ignored on the events channel
 			if !skip {
-				if !w.sendEvent(w.newEvent(name, mask)) {
+				if !w.sendEvent(w.newEvent(name, mask, raw.Cookie)) {
 					return
 				}
 			}
@@ -591,7 +613,7 @@ func (w *Watcher) readEvents() {
 }
 
 // newEvent returns an platform-independent Event based on an inotify mask.
-func (w *Watcher) newEvent(name string, mask uint32) Event {
+func (w *Watcher) 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 {
 		e.Op |= Create
@@ -608,5 +630,28 @@ func (w *Watcher) newEvent(name string, mask uint32) Event {
 	if mask&unix.IN_ATTRIB == unix.IN_ATTRIB {
 		e.Op |= Chmod
 	}
+
+	if cookie != 0 {
+		if mask&unix.IN_MOVED_FROM == unix.IN_MOVED_FROM {
+			w.cookiesMu.Lock()
+			w.cookies[w.cookieIndex] = koekje{cookie: cookie, path: e.Name}
+			w.cookieIndex++
+			if w.cookieIndex > 9 {
+				w.cookieIndex = 0
+			}
+			w.cookiesMu.Unlock()
+		} else if mask&unix.IN_MOVED_TO == unix.IN_MOVED_TO {
+			w.cookiesMu.Lock()
+			var prev string
+			for _, c := range w.cookies {
+				if c.cookie == cookie {
+					prev = c.path
+					break
+				}
+			}
+			w.cookiesMu.Unlock()
+			e.renamedFrom = prev
+		}
+	}
 	return e
 }
diff --git a/backend_windows.go b/backend_windows.go
index 81d3744..564f52d 100644
--- a/backend_windows.go
+++ b/backend_windows.go
@@ -178,12 +178,13 @@ func (w *Watcher) isClosed() bool {
 	return w.closed
 }
 
-func (w *Watcher) sendEvent(name string, mask uint64) bool {
+func (w *Watcher) sendEvent(name, renamedFrom string, mask uint64) bool {
 	if mask == 0 {
 		return false
 	}
 
 	event := w.newEvent(name, uint32(mask))
+	event.renamedFrom = renamedFrom
 	select {
 	case ch := <-w.quit:
 		w.quit <- ch
@@ -275,7 +276,7 @@ func (w *Watcher) AddWith(name string, opts ...addOpt) error {
 	}
 	if debug {
 		fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s  AddWith(%q)\n",
-			time.Now().Format("15:04:05.000000000"), name)
+			time.Now().Format("15:04:05.000000000"), filepath.ToSlash(name))
 	}
 
 	with := getOptions(opts...)
@@ -311,7 +312,7 @@ func (w *Watcher) Remove(name string) error {
 	}
 	if debug {
 		fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s  Remove(%q)\n",
-			time.Now().Format("15:04:05.000000000"), name)
+			time.Now().Format("15:04:05.000000000"), filepath.ToSlash(name))
 	}
 
 	in := &input{
@@ -577,11 +578,11 @@ func (w *Watcher) remWatch(pathname string) error {
 		return fmt.Errorf("%w: %s", ErrNonExistentWatch, pathname)
 	}
 	if pathname == dir {
-		w.sendEvent(watch.path, watch.mask&sysFSIGNORED)
+		w.sendEvent(watch.path, "", watch.mask&sysFSIGNORED)
 		watch.mask = 0
 	} else {
 		name := filepath.Base(pathname)
-		w.sendEvent(filepath.Join(watch.path, name), watch.names[name]&sysFSIGNORED)
+		w.sendEvent(filepath.Join(watch.path, name), "", watch.names[name]&sysFSIGNORED)
 		delete(watch.names, name)
 	}
 
@@ -592,13 +593,13 @@ func (w *Watcher) remWatch(pathname string) error {
 func (w *Watcher) deleteWatch(watch *watch) {
 	for name, mask := range watch.names {
 		if mask&provisional == 0 {
-			w.sendEvent(filepath.Join(watch.path, name), mask&sysFSIGNORED)
+			w.sendEvent(filepath.Join(watch.path, name), "", mask&sysFSIGNORED)
 		}
 		delete(watch.names, name)
 	}
 	if watch.mask != 0 {
 		if watch.mask&provisional == 0 {
-			w.sendEvent(watch.path, watch.mask&sysFSIGNORED)
+			w.sendEvent(watch.path, "", watch.mask&sysFSIGNORED)
 		}
 		watch.mask = 0
 	}
@@ -635,7 +636,7 @@ func (w *Watcher) startRead(watch *watch) error {
 		err := os.NewSyscallError("ReadDirectoryChanges", rdErr)
 		if rdErr == windows.ERROR_ACCESS_DENIED && watch.mask&provisional == 0 {
 			// Watched directory was probably removed
-			w.sendEvent(watch.path, watch.mask&sysFSDELETESELF)
+			w.sendEvent(watch.path, "", watch.mask&sysFSDELETESELF)
 			err = nil
 		}
 		w.deleteWatch(watch)
@@ -711,7 +712,7 @@ func (w *Watcher) readEvents() {
 			}
 		case windows.ERROR_ACCESS_DENIED:
 			// Watched directory was probably removed
-			w.sendEvent(watch.path, watch.mask&sysFSDELETESELF)
+			w.sendEvent(watch.path, "", watch.mask&sysFSDELETESELF)
 			w.deleteWatch(watch)
 			w.startRead(watch)
 			continue
@@ -776,21 +777,22 @@ func (w *Watcher) readEvents() {
 				}
 			}
 
-			sendNameEvent := func() {
-				w.sendEvent(fullname, watch.names[name]&mask)
-			}
 			if raw.Action != windows.FILE_ACTION_RENAMED_NEW_NAME {
-				sendNameEvent()
+				w.sendEvent(fullname, "", watch.names[name]&mask)
 			}
 			if raw.Action == windows.FILE_ACTION_REMOVED {
-				w.sendEvent(fullname, watch.names[name]&sysFSIGNORED)
+				w.sendEvent(fullname, "", watch.names[name]&sysFSIGNORED)
 				delete(watch.names, name)
 			}
 
-			w.sendEvent(fullname, watch.mask&w.toFSnotifyFlags(raw.Action))
+			if watch.rename != "" && raw.Action == windows.FILE_ACTION_RENAMED_NEW_NAME {
+				w.sendEvent(fullname, filepath.Join(watch.path, watch.rename), watch.mask&w.toFSnotifyFlags(raw.Action))
+			} else {
+				w.sendEvent(fullname, "", watch.mask&w.toFSnotifyFlags(raw.Action))
+			}
+
 			if raw.Action == windows.FILE_ACTION_RENAMED_NEW_NAME {
-				fullname = filepath.Join(watch.path, watch.rename)
-				sendNameEvent()
+				w.sendEvent(filepath.Join(watch.path, watch.rename), "", watch.names[name]&mask)
 			}
 
 			// Move to the next event in the buffer
@@ -802,8 +804,7 @@ func (w *Watcher) readEvents() {
 			// Error!
 			if offset >= n {
 				//lint:ignore ST1005 Windows should be capitalized
-				w.sendError(errors.New(
-					"Windows system assumed buffer larger than it is, events have likely been missed"))
+				w.sendError(errors.New("Windows system assumed buffer larger than it is, events have likely been missed"))
 				break
 			}
 		}
diff --git a/fsnotify.go b/fsnotify.go
index d02fd71..21bf887 100644
--- a/fsnotify.go
+++ b/fsnotify.go
@@ -46,6 +46,16 @@ type Event struct {
 	// This is a bitmask and some systems may send multiple operations at once.
 	// Use the Event.Has() method instead of comparing with ==.
 	Op Op
+
+	// Create events will have this set to the old path if it's a rename. This
+	// only works when both the source and destination are watched. It's not
+	// reliable when watching individual files, only directories.
+	//
+	// For example "mv /tmp/file /tmp/rename" will emit:
+	//
+	//   Event{Op: Rename, Name: "/tmp/file"}
+	//   Event{Op: Create, Name: "/tmp/rename", RenamedFrom: "/tmp/file"}
+	renamedFrom string
 }
 
 // Op describes a set of file operations.
@@ -128,6 +138,9 @@ func (e Event) Has(op Op) bool { return e.Op.Has(op) }
 
 // String returns a string representation of the event with their path.
 func (e Event) String() string {
+	if e.renamedFrom != "" {
+		return fmt.Sprintf("%-13s %q ← %q", e.Op.String(), e.Name, e.renamedFrom)
+	}
 	return fmt.Sprintf("%-13s %q", e.Op.String(), e.Name)
 }
 
diff --git a/internal/debug_linux.go b/internal/debug_linux.go
index 149d5e3..35c734b 100644
--- a/internal/debug_linux.go
+++ b/internal/debug_linux.go
@@ -9,45 +9,26 @@ import (
 	"golang.org/x/sys/unix"
 )
 
-func Debug(name string, mask uint32) {
+func Debug(name string, mask, cookie uint32) {
 	names := []struct {
 		n string
 		m uint32
 	}{
 		{"IN_ACCESS", unix.IN_ACCESS},
-		{"IN_ALL_EVENTS", unix.IN_ALL_EVENTS},
 		{"IN_ATTRIB", unix.IN_ATTRIB},
-		{"IN_CLASSA_HOST", unix.IN_CLASSA_HOST},
-		{"IN_CLASSA_MAX", unix.IN_CLASSA_MAX},
-		{"IN_CLASSA_NET", unix.IN_CLASSA_NET},
-		{"IN_CLASSA_NSHIFT", unix.IN_CLASSA_NSHIFT},
-		{"IN_CLASSB_HOST", unix.IN_CLASSB_HOST},
-		{"IN_CLASSB_MAX", unix.IN_CLASSB_MAX},
-		{"IN_CLASSB_NET", unix.IN_CLASSB_NET},
-		{"IN_CLASSB_NSHIFT", unix.IN_CLASSB_NSHIFT},
-		{"IN_CLASSC_HOST", unix.IN_CLASSC_HOST},
-		{"IN_CLASSC_NET", unix.IN_CLASSC_NET},
-		{"IN_CLASSC_NSHIFT", unix.IN_CLASSC_NSHIFT},
 		{"IN_CLOSE", unix.IN_CLOSE},
 		{"IN_CLOSE_NOWRITE", unix.IN_CLOSE_NOWRITE},
 		{"IN_CLOSE_WRITE", unix.IN_CLOSE_WRITE},
 		{"IN_CREATE", unix.IN_CREATE},
 		{"IN_DELETE", unix.IN_DELETE},
 		{"IN_DELETE_SELF", unix.IN_DELETE_SELF},
-		{"IN_DONT_FOLLOW", unix.IN_DONT_FOLLOW},
-		{"IN_EXCL_UNLINK", unix.IN_EXCL_UNLINK},
 		{"IN_IGNORED", unix.IN_IGNORED},
 		{"IN_ISDIR", unix.IN_ISDIR},
-		{"IN_LOOPBACKNET", unix.IN_LOOPBACKNET},
-		{"IN_MASK_ADD", unix.IN_MASK_ADD},
-		{"IN_MASK_CREATE", unix.IN_MASK_CREATE},
 		{"IN_MODIFY", unix.IN_MODIFY},
 		{"IN_MOVE", unix.IN_MOVE},
 		{"IN_MOVED_FROM", unix.IN_MOVED_FROM},
 		{"IN_MOVED_TO", unix.IN_MOVED_TO},
 		{"IN_MOVE_SELF", unix.IN_MOVE_SELF},
-		{"IN_ONESHOT", unix.IN_ONESHOT},
-		{"IN_ONLYDIR", unix.IN_ONLYDIR},
 		{"IN_OPEN", unix.IN_OPEN},
 		{"IN_Q_OVERFLOW", unix.IN_Q_OVERFLOW},
 		{"IN_UNMOUNT", unix.IN_UNMOUNT},
@@ -66,6 +47,10 @@ func Debug(name string, mask uint32) {
 	if unknown > 0 {
 		l = append(l, fmt.Sprintf("0x%x", unknown))
 	}
-	fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s  %10d:%-30s → %q\n",
-		time.Now().Format("15:04:05.000000000"), mask, strings.Join(l, " | "), name)
+	var c string
+	if cookie > 0 {
+		c = fmt.Sprintf("(cookie: %d) ", cookie)
+	}
+	fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s  %-30s → %s%q\n",
+		time.Now().Format("15:04:05.000000000"), strings.Join(l, "|"), c, name)
 }
diff --git a/internal/debug_windows.go b/internal/debug_windows.go
index c1025e4..52bf4ce 100644
--- a/internal/debug_windows.go
+++ b/internal/debug_windows.go
@@ -3,6 +3,7 @@ package internal
 import (
 	"fmt"
 	"os"
+	"path/filepath"
 	"strings"
 	"time"
 
@@ -14,14 +15,6 @@ func Debug(name string, mask uint32) {
 		n string
 		m uint32
 	}{
-		//{"FILE_NOTIFY_CHANGE_FILE_NAME", windows.FILE_NOTIFY_CHANGE_FILE_NAME},
-		//{"FILE_NOTIFY_CHANGE_DIR_NAME", windows.FILE_NOTIFY_CHANGE_DIR_NAME},
-		//{"FILE_NOTIFY_CHANGE_ATTRIBUTES", windows.FILE_NOTIFY_CHANGE_ATTRIBUTES},
-		//{"FILE_NOTIFY_CHANGE_SIZE", windows.FILE_NOTIFY_CHANGE_SIZE},
-		//{"FILE_NOTIFY_CHANGE_LAST_WRITE", windows.FILE_NOTIFY_CHANGE_LAST_WRITE},
-		//{"FILE_NOTIFY_CHANGE_LAST_ACCESS", windows.FILE_NOTIFY_CHANGE_LAST_ACCESS},
-		//{"FILE_NOTIFY_CHANGE_CREATION", windows.FILE_NOTIFY_CHANGE_CREATION},
-		//{"FILE_NOTIFY_CHANGE_SECURITY", windows.FILE_NOTIFY_CHANGE_SECURITY},
 		{"FILE_ACTION_ADDED", windows.FILE_ACTION_ADDED},
 		{"FILE_ACTION_REMOVED", windows.FILE_ACTION_REMOVED},
 		{"FILE_ACTION_MODIFIED", windows.FILE_ACTION_MODIFIED},
@@ -42,6 +35,6 @@ func Debug(name string, mask uint32) {
 	if unknown > 0 {
 		l = append(l, fmt.Sprintf("0x%x", unknown))
 	}
-	fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s  %2d:%-65s → %q\n",
-		time.Now().Format("15:04:05.000000000"), mask, strings.Join(l, " | "), name)
+	fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s  %-65s → %q\n",
+		time.Now().Format("15:04:05.000000000"), strings.Join(l, " | "), filepath.ToSlash(name))
 }


Test patch:
diff --git a/fsnotify_test.go b/fsnotify_test.go
index 62b6526..ac7d142 100644
--- a/fsnotify_test.go
+++ b/fsnotify_test.go
@@ -621,15 +621,15 @@ func TestEventString(t *testing.T) {
 		want string
 	}{
 		{Event{}, `[no events]   ""`},
-		{Event{"/file", 0}, `[no events]   "/file"`},
+		{Event{Name: "/file", Op: 0}, `[no events]   "/file"`},
 
-		{Event{"/file", Chmod | Create},
+		{Event{Name: "/file", Op: Chmod | Create},
 			`CREATE|CHMOD  "/file"`},
-		{Event{"/file", Rename},
+		{Event{Name: "/file", Op: Rename},
 			`RENAME        "/file"`},
-		{Event{"/file", Remove},
+		{Event{Name: "/file", Op: Remove},
 			`REMOVE        "/file"`},
-		{Event{"/file", Write | Chmod},
+		{Event{Name: "/file", Op: Write | Chmod},
 			`WRITE|CHMOD   "/file"`},
 	}
 
diff --git a/helpers_test.go b/helpers_test.go
index a4fb3a3..52b9c69 100644
--- a/helpers_test.go
+++ b/helpers_test.go
@@ -440,7 +440,11 @@ func (e Events) String() string {
 		if i > 0 {
 			b.WriteString("\n")
 		}
-		fmt.Fprintf(b, "%-20s %q", ee.Op.String(), filepath.ToSlash(ee.Name))
+		if ee.renamedFrom != "" {
+			fmt.Fprintf(b, "%-8s %s ← %s", ee.Op.String(), filepath.ToSlash(ee.Name), filepath.ToSlash(ee.renamedFrom))
+		} else {
+			fmt.Fprintf(b, "%-8s %s", ee.Op.String(), filepath.ToSlash(ee.Name))
+		}
 	}
 	return b.String()
 }
@@ -452,6 +456,11 @@ func (e Events) TrimPrefix(prefix string) Events {
 		} else {
 			e[i].Name = strings.TrimPrefix(e[i].Name, prefix)
 		}
+		if e[i].renamedFrom == prefix {
+			e[i].renamedFrom = "/"
+		} else {
+			e[i].renamedFrom = strings.TrimPrefix(e[i].renamedFrom, prefix)
+		}
 	}
 	return e
 }
@@ -507,44 +516,47 @@ func newEvents(t *testing.T, s string) Events {
 		}
 
 		fields := strings.Fields(line)
-		if len(fields) < 2 {
-			if strings.ToUpper(fields[0]) == "EMPTY" || strings.ToLower(fields[0]) == "no-events" {
+		if len(fields) != 2 && len(fields) != 4 {
+			if strings.ToLower(fields[0]) == "empty" || strings.ToLower(fields[0]) == "no-events" {
 				for _, g := range groups {
 					events[g] = Events{}
 				}
 				continue
 			}
-
-			t.Fatalf("newEvents: line %d has less than 2 fields: %s", no, line)
+			t.Fatalf("newEvents: line %d: needs 2 or 4 fields: %s", no+1, line)
 		}
 
-		path := strings.Trim(fields[len(fields)-1], `"`)
-
 		var op Op
-		for _, e := range fields[:len(fields)-1] {
-			if e == "|" {
-				continue
+		for _, ee := range strings.Split(fields[0], "|") {
+			switch strings.ToUpper(ee) {
+			case "CREATE":
+				op |= Create
+			case "WRITE":
+				op |= Write
+			case "REMOVE":
+				op |= Remove
+			case "RENAME":
+				op |= Rename
+			case "CHMOD":
+				op |= Chmod
+			default:
+				t.Fatalf("newEvents: line %d has unknown event %q: %s", no+1, ee, line)
 			}
-			for _, ee := range strings.Split(e, "|") {
-				switch strings.ToUpper(ee) {
-				case "CREATE":
-					op |= Create
-				case "WRITE":
-					op |= Write
-				case "REMOVE":
-					op |= Remove
-				case "RENAME":
-					op |= Rename
-				case "CHMOD":
-					op |= Chmod
-				default:
-					t.Fatalf("newEvents: line %d has unknown event %q: %s", no, ee, line)
-				}
+		}
+
+		var from string
+		if len(fields) > 2 {
+			if fields[2] != "←" {
+				t.Fatalf("newEvents: line %d: invalid format: %s", no+1, line)
 			}
+			from = strings.Trim(fields[3], `"`)
+		}
+		if !supportsRename() {
+			from = ""
 		}
 
 		for _, g := range groups {
-			events[g] = append(events[g], Event{Name: path, Op: op})
+			events[g] = append(events[g], Event{Name: strings.Trim(fields[1], `"`), renamedFrom: from, Op: op})
 		}
 	}
 
@@ -621,6 +633,15 @@ func recurseOnly(t *testing.T) {
 	}
 }
 
+func supportsRename() bool {
+	switch runtime.GOOS {
+	case "linux", "windows":
+		return true
+	default:
+		return false
+	}
+}
+
 func tmppath(tmp, s string) string {
 	if len(s) == 0 {
 		return ""
diff --git a/testdata/watch-dir/bug-277 b/testdata/watch-dir/bug-277
index cac00e1..0e17f02 100644
--- a/testdata/watch-dir/bug-277
+++ b/testdata/watch-dir/bug-277
@@ -15,12 +15,12 @@ mv     /apple /pear
 rm -r  /pear
 
 Output:
-	create   /foo     # touch foo
-	remove   /foo     # rm foo
-	create   /apple   # mkdir apple
-	rename   /apple   # mv apple pear
-	create   /pear
-	remove   /pear    # rm -r pear
+	create   /foo     # touch /foo
+	remove   /foo     # rm /foo
+	create   /apple   # mkdir /apple
+	rename   /apple   # mv /apple /pear
+	create   /pear ← /apple
+	remove   /pear    # rm -r /pear
 
 	dragonfly:
 		create   /foo
diff --git a/testdata/watch-dir/rename-file b/testdata/watch-dir/rename-file
index 61c8124..2580bb2 100644
--- a/testdata/watch-dir/rename-file
+++ b/testdata/watch-dir/rename-file
@@ -2,8 +2,8 @@
 
 echo asd >>/file
 watch /
-mv /file /renamed
+mv /file /rename
 
 Output:
 	rename /file
-	create /renamed
+	create /rename ← /file
diff --git a/testdata/watch-dir/rename-from-other-watch b/testdata/watch-dir/rename-from-other-watch
new file mode 100644
index 0000000..6932811
--- /dev/null
+++ b/testdata/watch-dir/rename-from-other-watch
@@ -0,0 +1,22 @@
+# Rename from one watched directory to another watched directory.
+
+mkdir /dir1
+mkdir /dir2
+touch /dir1/file
+
+watch /dir1
+watch /dir2
+
+mv /dir1/file /dir2/rename
+
+Output:
+	rename /dir1/file
+	create /dir2/rename ← /dir1/file
+
+	# Windows just doesn't send anything for this; seems like all watches are
+	# independent.
+	#
+	# TODO: consider emulating this behaviour on other platforms.
+	windows:
+		remove /dir1/file
+		create /dir2/rename
diff --git a/testdata/watch-dir/rename-file-overwrite b/testdata/watch-dir/rename-from-unwatched-overwrite
similarity index 89%
rename from testdata/watch-dir/rename-file-overwrite
rename to testdata/watch-dir/rename-from-unwatched-overwrite
index 4a53c81..d137b14 100644
--- a/testdata/watch-dir/rename-file-overwrite
+++ b/testdata/watch-dir/rename-from-unwatched-overwrite
@@ -10,7 +10,6 @@ watch /dir
 mv /unwatch/file /dir/rename
 
 Output:
-	# TODO: this should really be RENAME.
 	remove /dir/rename
 	create /dir/rename
 
diff --git a/testdata/watch-dir/rename-overwrite b/testdata/watch-dir/rename-overwrite
new file mode 100644
index 0000000..0e6a848
--- /dev/null
+++ b/testdata/watch-dir/rename-overwrite
@@ -0,0 +1,22 @@
+# Rename file in watched dir, overwriting an existing file.
+
+touch /file
+touch /rename
+
+watch /
+
+mv /file /rename
+
+Output:
+	remove  /rename
+	rename  /file
+	create  /rename ← /file
+
+	# Inotify just sends MOVED_FROM and MOVED_TO.
+	linux:
+		rename /file
+		create /rename ← /file
+
+	dragonfly:
+		remove /
+		rename /file
diff --git a/testdata/watch-dir/rename-symlink b/testdata/watch-dir/rename-symlink
index 36caae6..3c48c17 100644
--- a/testdata/watch-dir/rename-symlink
+++ b/testdata/watch-dir/rename-symlink
@@ -10,7 +10,7 @@ mv /link /link-rename
 
 Output:
 	rename /link
-	create /link-rename
+	create /link-rename ← /link
 
 	kqueue:  # TODO: this is broken.
 		create /link-rename
diff --git a/testdata/watch-file/overwrite-watched-file b/testdata/watch-file/overwrite-watched-file
new file mode 100644
index 0000000..de6f8e3
--- /dev/null
+++ b/testdata/watch-file/overwrite-watched-file
@@ -0,0 +1,29 @@
+# Overwrite watched file with non-watched file.
+
+touch /file
+touch /unwatch
+
+watch /file
+
+mv /unwatch /file
+
+echo asd >>/file
+rm /file
+
+# TODO: think what the best behaviour is here; do we want to keep the watch or
+# lose it? It's inconsistent now.
+Output:
+	remove   /file
+
+	linux:
+		chmod    /file
+		remove   /file
+
+	kqueue:
+		remove   /file
+		create   /file
+		write    /file
+		remove   /file
+
+	dragonfly:
+		no-events
diff --git a/testdata/watch-file/overwrite-watched-file-with-watched-file b/testdata/watch-file/overwrite-watched-file-with-watched-file
new file mode 100644
index 0000000..15ca7f5
--- /dev/null
+++ b/testdata/watch-file/overwrite-watched-file-with-watched-file
@@ -0,0 +1,41 @@
+# Overwrite watched file with non-watched file.
+
+touch /file
+touch /other
+
+watch /file
+watch /other
+
+mv /other /file
+
+echo asd >>/file
+rm /file
+
+# TODO: think what the best behaviour is here; do we want to keep the watch or
+# lose it? It's inconsistent now.
+Output:
+	# On inotify we just get IN_MOVE_SELF and IN_DELETE_SELF without cookie; no
+	# way to track this.
+	chmod    /file
+	rename   /other
+	remove   /file
+
+	kqueue:
+		rename   /other
+		remove   /file
+		create   /file
+		write    /file
+		remove   /file
+
+	windows:
+		remove   /file
+		rename   /other
+		write    /file
+		remove   /file
+
+	fen:
+		remove   /file
+		rename   /other
+
+	dragonfly:
+		rename /other
diff --git a/testdata/watch-recurse/rename-dir b/testdata/watch-recurse/rename-dir
index 5aa8ed4..c95ae97 100644
--- a/testdata/watch-recurse/rename-dir
+++ b/testdata/watch-recurse/rename-dir
@@ -10,7 +10,7 @@ touch /sub-rename/dir/file
 
 Output:
 	rename     /sub                  # mv /sub /sub-rename
-	create     /sub-rename
+	create     /sub-rename ← /sub
 
 	write      /sub-rename           # touch /sub-rename/file
 	create     /sub-rename/file