]> go.fuhry.dev Git - fsnotify.git/commitdiff
Add recursive watcher for Windows backend (#540)
authorMartin Tournoij <martin@arp242.net>
Tue, 20 Dec 2022 12:50:49 +0000 (13:50 +0100)
committerGitHub <noreply@github.com>
Tue, 20 Dec 2022 12:50:49 +0000 (13:50 +0100)
Recursive watches can be added by using a "/..." parameter, similar to
the Go command:

w.Add("dir")         // Behaves as before.
w.Add("dir/...")     // Watch dir and all paths underneath it.

w.Remove("dir")      // Remove the watch for dir and, if
                     // recursive, all paths underneath it too

w.Remove("dir/...")  // Behaves like just "dir" if the path was
                     // recursive, error otherwise (probably
                     // want to add recursive remove too at some
                     // point).

The advantage of using "/..." vs. an option is that it can be easily
specified in configuration files and the like; for example from a TOML
file:

[watches]
dirs = ["/tmp/one", "/tmp/two/..."]

Options for this were previously discussed at:
https://github.com/fsnotify/fsnotify/pull/339#discussion_r788246013

This should be expanded to other backends too; I started with Windows
because the implementation is the both the easiest and has the least
amount of control (just setting a boolean parameter), and we can focus
mostly on writing tests and documentation and the for it, and we can
then match the inotify and kqueue behaviour to the Windows one.

Fixes #21

Co-authored-by: Milas Bowman <milasb@gmail.com>
CHANGELOG.md
backend_fen.go
backend_inotify.go
backend_inotify_test.go
backend_kqueue.go
backend_other.go
backend_windows.go
fsnotify.go
fsnotify_test.go
helpers_test.go
mkdoc.zsh

index 31fa1a47d61e197c7bc5982e7912a07fc932823c..5e08ba521166e2d9a43b01d920a943804d829452 100644 (file)
@@ -10,6 +10,8 @@ Unreleased
 - all: add `AddWith()`, which is identical to `Add()` but allows passing
   options. ([#521])
 
+- all: support recursively watching paths with `Add("path/...")`. ([#540])
+
 - windows: allow setting the buffer size with `fsnotify.WithBufferSize()`; the
   default of 64K is the highest value that works on all platforms and is enough
   for most purposes, but in some cases a highest buffer is needed. ([#521])
@@ -46,7 +48,7 @@ Unreleased
 
 - other: use the backend_other.go no-op if the `appengine` build tag is set;
   Google AppEngine forbids usage of the unsafe package so the inotify backend
-  won't work there.
+  won't compile there.
 
 
 [#371]: https://github.com/fsnotify/fsnotify/pull/371
@@ -58,6 +60,7 @@ Unreleased
 [#526]: https://github.com/fsnotify/fsnotify/pull/526
 [#528]: https://github.com/fsnotify/fsnotify/pull/528
 [#537]: https://github.com/fsnotify/fsnotify/pull/537
+[#540]: https://github.com/fsnotify/fsnotify/pull/540
 
 1.6.0 - 2022-10-13
 -------------------
index 255e53341f05be3268ef3ea61fa8648fe82b5eba..2c3e43dc3173031cc217bcff5a608bc33f4fab1a 100644 (file)
@@ -1,6 +1,9 @@
 //go:build solaris
 // +build solaris
 
+// Note: the documentation on the Watcher type and methods is generated from
+// mkdoc.zsh
+
 package fsnotify
 
 import (
@@ -63,6 +66,16 @@ import (
 // The sysctl variables kern.maxfiles and kern.maxfilesperproc can be used to
 // control the maximum number of open files, as well as /etc/login.conf on BSD
 // systems.
+//
+// # Windows notes
+//
+// Paths can be added as "C:\path\to\dir", but forward slashes
+// ("C:/path/to/dir") will also work.
+//
+// The default buffer size is 64K, which is the largest value that is guaranteed
+// to work with SMB filesystems. If you have many events in quick succession
+// this may not be enough, and you will have to use [WithBufferSize] to increase
+// the value.
 type Watcher struct {
        // Events sends the filesystem change events.
        //
@@ -92,6 +105,8 @@ type Watcher struct {
        //                      you may get hundreds of Write events, so you
        //                      probably want to wait until you've stopped receiving
        //                      them (see the dedup example in cmd/fsnotify).
+       //                      Some systems may send Write event for directories
+       //                      when the directory content changes.
        //
        //   fsnotify.Chmod     Attributes were changed. On Linux this is also sent
        //                      when a file is removed (or more accurately, when a
@@ -184,7 +199,7 @@ func (w *Watcher) Close() error {
 //
 // A path can only be watched once; attempting to watch it more than once will
 // return an error. Paths that do not yet exist on the filesystem cannot be
-// added.
+// watched.
 //
 // A watch will be automatically removed if the watched path is deleted or
 // renamed. The exception is the Windows backend, which doesn't remove the
@@ -200,8 +215,9 @@ func (w *Watcher) Close() error {
 // # Watching directories
 //
 // All files in a directory are monitored, including new files that are created
-// after the watcher is started. Subdirectories are not watched (i.e. it's
-// non-recursive).
+// after the watcher is started. By default subdirectories are not watched (i.e.
+// it's non-recursive), but if the path ends with "/..." all files and
+// subdirectories are watched too.
 //
 // # Watching files
 //
@@ -266,8 +282,15 @@ func (w *Watcher) AddWith(name string, opts ...addOpt) error {
 
 // Remove stops monitoring the path for changes.
 //
-// Directories are always removed non-recursively. For example, if you added
-// /tmp/dir and /tmp/dir/subdir then you will need to remove both.
+// If the path was added as a recursive watch (e.g. as "/tmp/dir/...") then the
+// entire recursive watch will be removed. You can use either "/tmp/dir" or
+// "/tmp/dir/..." (they behave identically).
+//
+// You cannot remove individual files or subdirectories from recursive watches;
+// e.g. Add("/tmp/path/...") and then Remove("/tmp/path/sub") will fail.
+//
+// For other watches directories are removed non-recursively. For example, if
+// you added "/tmp/dir" and "/tmp/dir/subdir" then you will need to remove both.
 //
 // Removing a path that has not yet been added returns [ErrNonExistentWatch].
 //
index 2f47f4da157a08b1e0c5569d6d3c13b216ac332a..d9cb3a0286e09f8fc02416209cc03a212d4bb4dd 100644 (file)
@@ -1,6 +1,9 @@
 //go:build linux && !appengine
 // +build linux,!appengine
 
+// Note: the documentation on the Watcher type and methods is generated from
+// mkdoc.zsh
+
 package fsnotify
 
 import (
@@ -66,6 +69,16 @@ import (
 // The sysctl variables kern.maxfiles and kern.maxfilesperproc can be used to
 // control the maximum number of open files, as well as /etc/login.conf on BSD
 // systems.
+//
+// # Windows notes
+//
+// Paths can be added as "C:\path\to\dir", but forward slashes
+// ("C:/path/to/dir") will also work.
+//
+// The default buffer size is 64K, which is the largest value that is guaranteed
+// to work with SMB filesystems. If you have many events in quick succession
+// this may not be enough, and you will have to use [WithBufferSize] to increase
+// the value.
 type Watcher struct {
        // Events sends the filesystem change events.
        //
@@ -95,6 +108,8 @@ type Watcher struct {
        //                      you may get hundreds of Write events, so you
        //                      probably want to wait until you've stopped receiving
        //                      them (see the dedup example in cmd/fsnotify).
+       //                      Some systems may send Write event for directories
+       //                      when the directory content changes.
        //
        //   fsnotify.Chmod     Attributes were changed. On Linux this is also sent
        //                      when a file is removed (or more accurately, when a
@@ -206,7 +221,7 @@ func (w *Watcher) Close() error {
 //
 // A path can only be watched once; attempting to watch it more than once will
 // return an error. Paths that do not yet exist on the filesystem cannot be
-// added.
+// watched.
 //
 // A watch will be automatically removed if the watched path is deleted or
 // renamed. The exception is the Windows backend, which doesn't remove the
@@ -222,8 +237,9 @@ func (w *Watcher) Close() error {
 // # Watching directories
 //
 // All files in a directory are monitored, including new files that are created
-// after the watcher is started. Subdirectories are not watched (i.e. it's
-// non-recursive).
+// after the watcher is started. By default subdirectories are not watched (i.e.
+// it's non-recursive), but if the path ends with "/..." all files and
+// subdirectories are watched too.
 //
 // # Watching files
 //
@@ -281,8 +297,15 @@ func (w *Watcher) AddWith(name string, opts ...addOpt) error {
 
 // Remove stops monitoring the path for changes.
 //
-// Directories are always removed non-recursively. For example, if you added
-// /tmp/dir and /tmp/dir/subdir then you will need to remove both.
+// If the path was added as a recursive watch (e.g. as "/tmp/dir/...") then the
+// entire recursive watch will be removed. You can use either "/tmp/dir" or
+// "/tmp/dir/..." (they behave identically).
+//
+// You cannot remove individual files or subdirectories from recursive watches;
+// e.g. Add("/tmp/path/...") and then Remove("/tmp/path/sub") will fail.
+//
+// For other watches directories are removed non-recursively. For example, if
+// you added "/tmp/dir" and "/tmp/dir/subdir" then you will need to remove both.
 //
 // Removing a path that has not yet been added returns [ErrNonExistentWatch].
 //
index c857291adeb89f327751d79563e20f0dac69afe1..6965ee19dcb960dff5a7d19d9f76134e57c097c2 100644 (file)
@@ -93,6 +93,7 @@ func TestInotifyDeleteOpenFile(t *testing.T) {
        w.collect(t)
 
        rm(t, file)
+       eventSeparator()
        e := w.events(t)
        cmpEvents(t, tmp, e, newEvents(t, `chmod /file`))
 
index b83e7798571476222b74bd2b632765bce35cc2b4..c02ad5a9e99e69a4dc14d72f17e2d5172aa3b996 100644 (file)
@@ -1,6 +1,9 @@
 //go:build freebsd || openbsd || netbsd || dragonfly || darwin
 // +build freebsd openbsd netbsd dragonfly darwin
 
+// Note: the documentation on the Watcher type and methods is generated from
+// mkdoc.zsh
+
 package fsnotify
 
 import (
@@ -63,6 +66,16 @@ import (
 // The sysctl variables kern.maxfiles and kern.maxfilesperproc can be used to
 // control the maximum number of open files, as well as /etc/login.conf on BSD
 // systems.
+//
+// # Windows notes
+//
+// Paths can be added as "C:\path\to\dir", but forward slashes
+// ("C:/path/to/dir") will also work.
+//
+// The default buffer size is 64K, which is the largest value that is guaranteed
+// to work with SMB filesystems. If you have many events in quick succession
+// this may not be enough, and you will have to use [WithBufferSize] to increase
+// the value.
 type Watcher struct {
        // Events sends the filesystem change events.
        //
@@ -92,6 +105,8 @@ type Watcher struct {
        //                      you may get hundreds of Write events, so you
        //                      probably want to wait until you've stopped receiving
        //                      them (see the dedup example in cmd/fsnotify).
+       //                      Some systems may send Write event for directories
+       //                      when the directory content changes.
        //
        //   fsnotify.Chmod     Attributes were changed. On Linux this is also sent
        //                      when a file is removed (or more accurately, when a
@@ -237,7 +252,7 @@ func (w *Watcher) Close() error {
 //
 // A path can only be watched once; attempting to watch it more than once will
 // return an error. Paths that do not yet exist on the filesystem cannot be
-// added.
+// watched.
 //
 // A watch will be automatically removed if the watched path is deleted or
 // renamed. The exception is the Windows backend, which doesn't remove the
@@ -253,8 +268,9 @@ func (w *Watcher) Close() error {
 // # Watching directories
 //
 // All files in a directory are monitored, including new files that are created
-// after the watcher is started. Subdirectories are not watched (i.e. it's
-// non-recursive).
+// after the watcher is started. By default subdirectories are not watched (i.e.
+// it's non-recursive), but if the path ends with "/..." all files and
+// subdirectories are watched too.
 //
 // # Watching files
 //
@@ -288,8 +304,15 @@ func (w *Watcher) AddWith(name string, opts ...addOpt) error {
 
 // Remove stops monitoring the path for changes.
 //
-// Directories are always removed non-recursively. For example, if you added
-// /tmp/dir and /tmp/dir/subdir then you will need to remove both.
+// If the path was added as a recursive watch (e.g. as "/tmp/dir/...") then the
+// entire recursive watch will be removed. You can use either "/tmp/dir" or
+// "/tmp/dir/..." (they behave identically).
+//
+// You cannot remove individual files or subdirectories from recursive watches;
+// e.g. Add("/tmp/path/...") and then Remove("/tmp/path/sub") will fail.
+//
+// For other watches directories are removed non-recursively. For example, if
+// you added "/tmp/dir" and "/tmp/dir/subdir" then you will need to remove both.
 //
 // Removing a path that has not yet been added returns [ErrNonExistentWatch].
 //
index bbf85a2f7497645d1933c9d2deec51099a964590..bc1cab11416d45987581bfeeba9a31de331bffc5 100644 (file)
@@ -1,6 +1,9 @@
 //go:build appengine || (!darwin && !dragonfly && !freebsd && !openbsd && !linux && !netbsd && !solaris && !windows)
 // +build appengine !darwin,!dragonfly,!freebsd,!openbsd,!linux,!netbsd,!solaris,!windows
 
+// Note: the documentation on the Watcher type and methods is generated from
+// mkdoc.zsh
+
 package fsnotify
 
 import "errors"
@@ -55,6 +58,16 @@ import "errors"
 // The sysctl variables kern.maxfiles and kern.maxfilesperproc can be used to
 // control the maximum number of open files, as well as /etc/login.conf on BSD
 // systems.
+//
+// # Windows notes
+//
+// Paths can be added as "C:\path\to\dir", but forward slashes
+// ("C:/path/to/dir") will also work.
+//
+// The default buffer size is 64K, which is the largest value that is guaranteed
+// to work with SMB filesystems. If you have many events in quick succession
+// this may not be enough, and you will have to use [WithBufferSize] to increase
+// the value.
 type Watcher struct {
        // Events sends the filesystem change events.
        //
@@ -84,6 +97,8 @@ type Watcher struct {
        //                      you may get hundreds of Write events, so you
        //                      probably want to wait until you've stopped receiving
        //                      them (see the dedup example in cmd/fsnotify).
+       //                      Some systems may send Write event for directories
+       //                      when the directory content changes.
        //
        //   fsnotify.Chmod     Attributes were changed. On Linux this is also sent
        //                      when a file is removed (or more accurately, when a
@@ -119,7 +134,7 @@ func (w *Watcher) WatchList() []string { return nil }
 //
 // A path can only be watched once; attempting to watch it more than once will
 // return an error. Paths that do not yet exist on the filesystem cannot be
-// added.
+// watched.
 //
 // A watch will be automatically removed if the watched path is deleted or
 // renamed. The exception is the Windows backend, which doesn't remove the
@@ -135,8 +150,9 @@ func (w *Watcher) WatchList() []string { return nil }
 // # Watching directories
 //
 // All files in a directory are monitored, including new files that are created
-// after the watcher is started. Subdirectories are not watched (i.e. it's
-// non-recursive).
+// after the watcher is started. By default subdirectories are not watched (i.e.
+// it's non-recursive), but if the path ends with "/..." all files and
+// subdirectories are watched too.
 //
 // # Watching files
 //
@@ -162,8 +178,15 @@ func (w *Watcher) AddWith(name string, opts ...addOpt) error { return nil }
 
 // Remove stops monitoring the path for changes.
 //
-// Directories are always removed non-recursively. For example, if you added
-// /tmp/dir and /tmp/dir/subdir then you will need to remove both.
+// If the path was added as a recursive watch (e.g. as "/tmp/dir/...") then the
+// entire recursive watch will be removed. You can use either "/tmp/dir" or
+// "/tmp/dir/..." (they behave identically).
+//
+// You cannot remove individual files or subdirectories from recursive watches;
+// e.g. Add("/tmp/path/...") and then Remove("/tmp/path/sub") will fail.
+//
+// For other watches directories are removed non-recursively. For example, if
+// you added "/tmp/dir" and "/tmp/dir/subdir" then you will need to remove both.
 //
 // Removing a path that has not yet been added returns [ErrNonExistentWatch].
 //
index 2dd9aff6415585ff7c3f10576c948d4753d76ec0..77050aed370bf510334e933d890dad6f88b03f0c 100644 (file)
@@ -1,6 +1,13 @@
 //go:build windows
 // +build windows
 
+// Windows backend based on ReadDirectoryChangesW()
+//
+// https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-readdirectorychangesw
+//
+// Note: the documentation on the Watcher type and methods is generated from
+// mkdoc.zsh
+
 package fsnotify
 
 import (
@@ -67,6 +74,16 @@ import (
 // The sysctl variables kern.maxfiles and kern.maxfilesperproc can be used to
 // control the maximum number of open files, as well as /etc/login.conf on BSD
 // systems.
+//
+// # Windows notes
+//
+// Paths can be added as "C:\path\to\dir", but forward slashes
+// ("C:/path/to/dir") will also work.
+//
+// The default buffer size is 64K, which is the largest value that is guaranteed
+// to work with SMB filesystems. If you have many events in quick succession
+// this may not be enough, and you will have to use [WithBufferSize] to increase
+// the value.
 type Watcher struct {
        // Events sends the filesystem change events.
        //
@@ -96,6 +113,8 @@ type Watcher struct {
        //                      you may get hundreds of Write events, so you
        //                      probably want to wait until you've stopped receiving
        //                      them (see the dedup example in cmd/fsnotify).
+       //                      Some systems may send Write event for directories
+       //                      when the directory content changes.
        //
        //   fsnotify.Chmod     Attributes were changed. On Linux this is also sent
        //                      when a file is removed (or more accurately, when a
@@ -193,7 +212,7 @@ func (w *Watcher) Close() error {
 //
 // A path can only be watched once; attempting to watch it more than once will
 // return an error. Paths that do not yet exist on the filesystem cannot be
-// added.
+// watched.
 //
 // A watch will be automatically removed if the watched path is deleted or
 // renamed. The exception is the Windows backend, which doesn't remove the
@@ -209,8 +228,9 @@ func (w *Watcher) Close() error {
 // # Watching directories
 //
 // All files in a directory are monitored, including new files that are created
-// after the watcher is started. Subdirectories are not watched (i.e. it's
-// non-recursive).
+// after the watcher is started. By default subdirectories are not watched (i.e.
+// it's non-recursive), but if the path ends with "/..." all files and
+// subdirectories are watched too.
 //
 // # Watching files
 //
@@ -258,8 +278,15 @@ func (w *Watcher) AddWith(name string, opts ...addOpt) error {
 
 // Remove stops monitoring the path for changes.
 //
-// Directories are always removed non-recursively. For example, if you added
-// /tmp/dir and /tmp/dir/subdir then you will need to remove both.
+// If the path was added as a recursive watch (e.g. as "/tmp/dir/...") then the
+// entire recursive watch will be removed. You can use either "/tmp/dir" or
+// "/tmp/dir/..." (they behave identically).
+//
+// You cannot remove individual files or subdirectories from recursive watches;
+// e.g. Add("/tmp/path/...") and then Remove("/tmp/path/sub") will fail.
+//
+// For other watches directories are removed non-recursively. For example, if
+// you added "/tmp/dir" and "/tmp/dir/subdir" then you will need to remove both.
 //
 // Removing a path that has not yet been added returns [ErrNonExistentWatch].
 //
@@ -362,13 +389,14 @@ type inode struct {
 }
 
 type watch struct {
-       ov     windows.Overlapped
-       ino    *inode            // i-number
-       path   string            // Directory path
-       mask   uint64            // Directory itself is being watched with these notify flags
-       names  map[string]uint64 // Map of names being watched and their notify flags
-       rename string            // Remembers the old name while renaming a file
-       buf    []byte            // buffer, allocated later
+       ov      windows.Overlapped
+       ino     *inode            // i-number
+       recurse bool              // Recursive watch?
+       path    string            // Directory path
+       mask    uint64            // Directory itself is being watched with these notify flags
+       names   map[string]uint64 // Map of names being watched and their notify flags
+       rename  string            // Remembers the old name while renaming a file
+       buf     []byte            // buffer, allocated later
 }
 
 type (
@@ -442,6 +470,7 @@ func (m watchMap) set(ino *inode, watch *watch) {
 
 // Must run within the I/O thread.
 func (w *Watcher) addWatch(pathname string, flags uint64, bufsize int) error {
+       pathname, recurse := recursivePath(pathname)
        dir, err := w.getDir(pathname)
        if err != nil {
                return err
@@ -461,10 +490,11 @@ func (w *Watcher) addWatch(pathname string, flags uint64, bufsize int) error {
                        return os.NewSyscallError("CreateIoCompletionPort", err)
                }
                watchEntry = &watch{
-                       ino:   ino,
-                       path:  dir,
-                       names: make(map[string]uint64),
-                       buf:   make([]byte, bufsize),
+                       ino:     ino,
+                       path:    dir,
+                       names:   make(map[string]uint64),
+                       recurse: recurse,
+                       buf:     make([]byte, bufsize),
                }
                w.mu.Lock()
                w.watches.set(ino, watchEntry)
@@ -494,6 +524,8 @@ func (w *Watcher) addWatch(pathname string, flags uint64, bufsize int) error {
 
 // Must run within the I/O thread.
 func (w *Watcher) remWatch(pathname string) error {
+       pathname, recurse := recursivePath(pathname)
+
        dir, err := w.getDir(pathname)
        if err != nil {
                return err
@@ -507,6 +539,10 @@ func (w *Watcher) remWatch(pathname string) error {
        watch := w.watches.get(ino)
        w.mu.Unlock()
 
+       if recurse && !watch.recurse {
+               return fmt.Errorf("can't use \\... with non-recursive watch %q", pathname)
+       }
+
        err = windows.CloseHandle(ino.handle)
        if err != nil {
                w.sendError(os.NewSyscallError("CloseHandle", err))
@@ -568,7 +604,7 @@ func (w *Watcher) startRead(watch *watch) error {
        hdr := (*reflect.SliceHeader)(unsafe.Pointer(&watch.buf))
        rdErr := windows.ReadDirectoryChanges(watch.ino.handle,
                (*byte)(unsafe.Pointer(hdr.Data)), uint32(hdr.Len),
-               false, mask, nil, &watch.ov, 0)
+               watch.recurse, mask, nil, &watch.ov, 0)
        if rdErr != nil {
                err := os.NewSyscallError("ReadDirectoryChanges", rdErr)
                if rdErr == windows.ERROR_ACCESS_DENIED && watch.mask&provisional == 0 {
@@ -595,9 +631,8 @@ func (w *Watcher) readEvents() {
        runtime.LockOSThread()
 
        for {
+               // This error is handled after the watch == nil check below.
                qErr := windows.GetQueuedCompletionStatus(w.port, &n, &key, &ov, windows.INFINITE)
-               // This error is handled after the watch == nil check below. NOTE: this
-               // seems odd, note sure if it's correct.
 
                watch := (*watch)(unsafe.Pointer(ov))
                if watch == nil {
index 142169da68ae97b8b87402f8a6164f0456a3ac75..c00ce762a6aeb2de2f5672c2c9b6d60d327ae8b4 100644 (file)
@@ -12,6 +12,7 @@ package fsnotify
 import (
        "errors"
        "fmt"
+       "path/filepath"
        "strings"
 )
 
@@ -131,3 +132,12 @@ func getOptions(opts ...addOpt) withOpts {
 func WithBufferSize(bytes int) addOpt {
        return func(opt *withOpts) { opt.bufsize = bytes }
 }
+
+// Check if this path is recursive (ends with "/..." or "\..."), and return the
+// path with the /... stripped.
+func recursivePath(path string) (string, bool) {
+       if filepath.Base(path) == "..." {
+               return filepath.Dir(path), true
+       }
+       return path, false
+}
index 12a284bef92d01844af5bf4e07be81538f26ac01..767654eac53eafdb52ea16465f8aa7797fe3f46f 100644 (file)
@@ -656,7 +656,7 @@ func TestWatchAttrib(t *testing.T) {
        }
 }
 
-func TestWatchRm(t *testing.T) {
+func TestWatchRemove(t *testing.T) {
        tests := []testCase{
                {"remove watched file", func(t *testing.T, w *Watcher, tmp string) {
                        file := join(tmp, "file")
@@ -759,6 +759,194 @@ func TestWatchRm(t *testing.T) {
                                WRITE                "/j"
                                WRITE                "/j"
                `},
+
+               {"remove recursive", func(t *testing.T, w *Watcher, tmp string) {
+                       recurseOnly(t)
+
+                       mkdirAll(t, tmp, "dir1", "subdir")
+                       mkdirAll(t, tmp, "dir2", "subdir")
+                       touch(t, tmp, "dir1", "subdir", "file")
+                       touch(t, tmp, "dir2", "subdir", "file")
+
+                       addWatch(t, w, tmp, "dir1", "...")
+                       addWatch(t, w, tmp, "dir2", "...")
+                       cat(t, "asd", tmp, "dir1", "subdir", "file")
+                       cat(t, "asd", tmp, "dir2", "subdir", "file")
+
+                       if err := w.Remove(join(tmp, "dir1")); err != nil {
+                               t.Fatal(err)
+                       }
+                       if err := w.Remove(join(tmp, "dir2", "...")); err != nil {
+                               t.Fatal(err)
+                       }
+
+                       if w := w.WatchList(); len(w) != 0 {
+                               t.Errorf("WatchList not empty: %s", w)
+                       }
+
+                       cat(t, "asd", tmp, "dir1", "subdir", "file")
+                       cat(t, "asd", tmp, "dir2", "subdir", "file")
+               }, `
+                       write /dir1/subdir
+                       write /dir1/subdir/file
+                       write /dir2/subdir
+                       write /dir2/subdir/file
+               `},
+       }
+
+       for _, tt := range tests {
+               tt := tt
+               tt.run(t)
+       }
+}
+
+func TestWatchRecursive(t *testing.T) {
+       recurseOnly(t)
+
+       tests := []testCase{
+               // Make a nested directory tree, then write some files there.
+               {"basic", func(t *testing.T, w *Watcher, tmp string) {
+                       mkdirAll(t, tmp, "/one/two/three/four")
+                       addWatch(t, w, tmp, "/...")
+
+                       cat(t, "asd", tmp, "/file.txt")
+                       cat(t, "asd", tmp, "/one/two/three/file.txt")
+               }, `
+                       create    /file.txt                  # cat asd >file.txt
+                       write     /file.txt
+
+                       write     /one/two/three             # cat asd >one/two/three/file.txt
+                       create    /one/two/three/file.txt
+                       write     /one/two/three/file.txt
+               `},
+
+               // Create a new directory tree and then some files under that.
+               {"add directory", func(t *testing.T, w *Watcher, tmp string) {
+                       mkdirAll(t, tmp, "/one/two/three/four")
+                       addWatch(t, w, tmp, "/...")
+
+                       mkdirAll(t, tmp, "/one/two/new/dir")
+                       touch(t, tmp, "/one/two/new/file")
+                       touch(t, tmp, "/one/two/new/dir/file")
+               }, `
+                       write     /one/two                # mkdir -p one/two/new/dir
+                       create    /one/two/new
+                       create    /one/two/new/dir
+
+                       write     /one/two/new            # touch one/two/new/file
+                       create    /one/two/new/file
+
+                       create    /one/two/new/dir/file   # touch one/two/new/dir/file
+               `},
+
+               // Remove nested directory
+               {"remove directory", func(t *testing.T, w *Watcher, tmp string) {
+                       mkdirAll(t, tmp, "one/two/three/four")
+                       addWatch(t, w, tmp, "...")
+
+                       cat(t, "asd", tmp, "one/two/three/file.txt")
+                       rmAll(t, tmp, "one/two")
+               }, `
+                       write                /one/two/three            # cat asd >one/two/three/file.txt
+                       create               /one/two/three/file.txt
+                       write                /one/two/three/file.txt
+
+                       write                /one/two                  # rm -r one/two
+                       write                /one/two/three
+                       remove               /one/two/three/file.txt
+                       remove               /one/two/three/four
+                       write                /one/two/three
+                       remove               /one/two/three
+                       write                /one/two
+                       remove               /one/two
+               `},
+
+               // Rename nested directory
+               {"rename directory", func(t *testing.T, w *Watcher, tmp string) {
+                       mkdirAll(t, tmp, "/one/two/three/four")
+                       addWatch(t, w, tmp, "...")
+
+                       mv(t, join(tmp, "one"), tmp, "one-rename")
+                       touch(t, tmp, "one-rename/file")
+                       touch(t, tmp, "one-rename/two/three/file")
+               }, `
+                       rename               "/one"                        # mv one one-rename
+                       create               "/one-rename"
+
+                       write                "/one-rename"                 # touch one-rename/file
+                       create               "/one-rename/file"
+
+                       write                "/one-rename/two/three"       # touch one-rename/two/three/file
+                       create               "/one-rename/two/three/file"
+               `},
+
+               {"remove watched directory", func(t *testing.T, w *Watcher, tmp string) {
+                       mk := func(r string) {
+                               touch(t, r, "a")
+                               touch(t, r, "b")
+                               touch(t, r, "c")
+                               touch(t, r, "d")
+                               touch(t, r, "e")
+                               touch(t, r, "f")
+                               touch(t, r, "g")
+
+                               mkdir(t, r, "h")
+                               mkdir(t, r, "h", "a")
+                               mkdir(t, r, "i")
+                               mkdir(t, r, "i", "a")
+                               mkdir(t, r, "j")
+                               mkdir(t, r, "j", "a")
+                       }
+                       mk(tmp)
+                       mkdir(t, tmp, "sub")
+                       mk(join(tmp, "sub"))
+
+                       addWatch(t, w, tmp, "...")
+                       rmAll(t, tmp)
+               }, `
+                       remove               "/a"
+                       remove               "/b"
+                       remove               "/c"
+                       remove               "/d"
+                       remove               "/e"
+                       remove               "/f"
+                       remove               "/g"
+                       write                "/h"
+                       remove               "/h/a"
+                       write                "/h"
+                       remove               "/h"
+                       write                "/i"
+                       remove               "/i/a"
+                       write                "/i"
+                       remove               "/i"
+                       write                "/j"
+                       remove               "/j/a"
+                       write                "/j"
+                       remove               "/j"
+                       write                "/sub"
+                       remove               "/sub/a"
+                       remove               "/sub/b"
+                       remove               "/sub/c"
+                       remove               "/sub/d"
+                       remove               "/sub/e"
+                       remove               "/sub/f"
+                       remove               "/sub/g"
+                       write                "/sub/h"
+                       remove               "/sub/h/a"
+                       write                "/sub/h"
+                       remove               "/sub/h"
+                       write                "/sub/i"
+                       remove               "/sub/i/a"
+                       write                "/sub/i"
+                       remove               "/sub/i"
+                       write                "/sub/j"
+                       remove               "/sub/j/a"
+                       write                "/sub/j"
+                       remove               "/sub/j"
+                       write                "/sub"
+                       remove               "/sub"
+                       remove               "/"
+               `},
        }
 
        for _, tt := range tests {
@@ -1063,6 +1251,23 @@ func TestRemove(t *testing.T) {
                        w.Close()
                }
        })
+
+       t.Run("remove with ... when non-recursive", func(t *testing.T) {
+               recurseOnly(t)
+               t.Parallel()
+
+               tmp := t.TempDir()
+               w := newWatcher(t)
+               addWatch(t, w, tmp)
+
+               if err := w.Remove(join(tmp, "...")); err == nil {
+                       t.Fatal("err was nil")
+               }
+               if err := w.Remove(tmp); err != nil {
+                       t.Fatal(err)
+               }
+       })
+
 }
 
 func TestEventString(t *testing.T) {
index 85c1fc2be3e2623e71f07ca18e2f82ceb22a865a..24f00299fa95e009da6039487f812fe0673b5291 100644 (file)
@@ -138,19 +138,19 @@ func mkdir(t *testing.T, path ...string) {
 }
 
 // mkdir -p
-// func mkdirAll(t *testing.T, path ...string) {
-//     t.Helper()
-//     if len(path) < 1 {
-//             t.Fatalf("mkdirAll: path must have at least one element: %s", path)
-//     }
-//     err := os.MkdirAll(join(path...), 0o0755)
-//     if err != nil {
-//             t.Fatalf("mkdirAll(%q): %s", join(path...), err)
-//     }
-//     if shouldWait(path...) {
-//             eventSeparator()
-//     }
-// }
+func mkdirAll(t *testing.T, path ...string) {
+       t.Helper()
+       if len(path) < 1 {
+               t.Fatalf("mkdirAll: path must have at least one element: %s", path)
+       }
+       err := os.MkdirAll(join(path...), 0o0755)
+       if err != nil {
+               t.Fatalf("mkdirAll(%q): %s", join(path...), err)
+       }
+       if shouldWait(path...) {
+               eventSeparator()
+       }
+}
 
 // ln -s
 func symlink(t *testing.T, target string, link ...string) {
@@ -576,3 +576,12 @@ func isSolaris() bool {
        }
        return false
 }
+
+func recurseOnly(t *testing.T) {
+       switch runtime.GOOS {
+       case "windows":
+               // Run test.
+       default:
+               t.Skip("recursion not yet supported on " + runtime.GOOS)
+       }
+}
index 228061e142ec8247e47566a5b55aa297e7901a84..c7180fb2162017b0f8773cd7b8aefa4c37f95826 100755 (executable)
--- a/mkdoc.zsh
+++ b/mkdoc.zsh
@@ -2,8 +2,8 @@
 [ "${ZSH_VERSION:-}" = "" ] && echo >&2 "Only works with zsh" && exit 1
 setopt err_exit no_unset pipefail extended_glob
 
-# Simple script to update the godoc comments on all watchers. Probably took me
-# more time to write this than doing it manually, but ah well ðŸ™ƒ
+# Simple script to update the godoc comments on all watchers so you don't need
+# to update the same comment 5 times.
 
 watcher=$(<<EOF
 // Watcher watches a set of paths, delivering events on a channel.
@@ -56,6 +56,16 @@ watcher=$(<<EOF
 // The sysctl variables kern.maxfiles and kern.maxfilesperproc can be used to
 // control the maximum number of open files, as well as /etc/login.conf on BSD
 // systems.
+//
+// # Windows notes
+//
+// Paths can be added as "C:\\path\\to\\dir", but forward slashes
+// ("C:/path/to/dir") will also work.
+//
+// The default buffer size is 64K, which is the largest value that is guaranteed
+// to work with SMB filesystems. If you have many events in quick succession
+// this may not be enough, and you will have to use [WithBufferSize] to increase
+// the value.
 EOF
 )
 
@@ -69,7 +79,7 @@ add=$(<<EOF
 //
 // A path can only be watched once; attempting to watch it more than once will
 // return an error. Paths that do not yet exist on the filesystem cannot be
-// added.
+// watched.
 //
 // A watch will be automatically removed if the watched path is deleted or
 // renamed. The exception is the Windows backend, which doesn't remove the
@@ -85,8 +95,9 @@ add=$(<<EOF
 // # Watching directories
 //
 // All files in a directory are monitored, including new files that are created
-// after the watcher is started. Subdirectories are not watched (i.e. it's
-// non-recursive).
+// after the watcher is started. By default subdirectories are not watched (i.e.
+// it's non-recursive), but if the path ends with "/..." all files and
+// subdirectories are watched too.
 //
 // # Watching files
 //
@@ -116,8 +127,15 @@ EOF
 remove=$(<<EOF
 // Remove stops monitoring the path for changes.
 //
-// Directories are always removed non-recursively. For example, if you added
-// /tmp/dir and /tmp/dir/subdir then you will need to remove both.
+// If the path was added as a recursive watch (e.g. as "/tmp/dir/...") then the
+// entire recursive watch will be removed. You can use either "/tmp/dir" or
+// "/tmp/dir/..." (they behave identically).
+//
+// You cannot remove individual files or subdirectories from recursive watches;
+// e.g. Add("/tmp/path/...") and then Remove("/tmp/path/sub") will fail.
+//
+// For other watches directories are removed non-recursively. For example, if
+// you added "/tmp/dir" and "/tmp/dir/subdir" then you will need to remove both.
 //
 // Removing a path that has not yet been added returns [ErrNonExistentWatch].
 //
@@ -166,6 +184,8 @@ events=$(<<EOF
        //                      you may get hundreds of Write events, so you
        //                      probably want to wait until you've stopped receiving
        //                      them (see the dedup example in cmd/fsnotify).
+       //                      Some systems may send Write event for directories
+       //                      when the directory content changes.
        //
        //   fsnotify.Chmod     Attributes were changed. On Linux this is also sent
        //                      when a file is removed (or more accurately, when a