Watchcat

Simple filesystem notification library for Ruby.

How

This gem uses Notify to get notifications.

Platforms

  • Linux: inotify
  • macOS: FSEvents
  • Windows: ReadDirectoryChangesW
  • All platforms: polling (via force_polling option)

Installation

Install the gem and add to the application's Gemfile by executing:

$ bundle add watchcat

If bundler is not being used to manage dependencies, install the gem by executing:

$ gem install watchcat

Usage

Please specify a filename or directory and callback block to Watchcat.watch. The callback will call when the specified file or directory is changed.

require "watchcat"

Watchcat.watch("/tmp/test") do |e|
  pp e.paths, e.kind
end

sleep

The value that is passed to the callback holds the paths that changed and the file change event. For example, if a file is created under the /tmp/test, you will get the following output.

["/tmp/test/a.txt"]
#<Watchcat::EventKind:0x00007f84be7161d8 @access=nil, @create=#<Watchcat::CreateKind:0x00007f84b99eaa08 @kind="file">, @modify=nil, @remove=nil>
["/tmp/test/a.txt"]
#<Watchcat::EventKind:0x00007f84be7159b8
 @access=nil,
 @create=nil,
 @modify=#<Watchcat::ModifyKind:0x00007f84be715968 @data_change=nil, @kind="metadata", @metadata=#<Watchcat::MetadataKind:0x00007f84b99e7a60 @kind=nil>, @rename=nil>,
 @remove=nil>
["/tmp/test/a.txt"]
#<Watchcat::EventKind:0x00007f84be714dd8
 @access=#<Watchcat::AccessKind:0x00007f84b99e3708 @access_mode=#<Watchcat::AccessMode:0x00007f84b99e3640 @mode="write">, @kind="close">,
 @create=nil,
 @modify=nil,
 @remove=nil>

You can know what event is happened with Watchcat::EventKind. For example, what a file is changed or not, you can check with Watchcat::EventKind#modify?. Seed the following example for details.

require "watchcat"

Watchcat.watch("/tmp/target") do |e|
  if e.kind.create?
    if e.kind.create.file?
      puts "'#{e.paths[0]}'(File) is added."
    elsif e.kind.create.folder?
      puts "'#{e.paths[0]}'(Folder) is added."
    end
  elsif e.kind.modify?
    if e.kind.modify.data_change?
      puts "'#{e.paths[0]}' is updated."
    end
  elsif e.kind.remove?
    if e.kind.remove.file?
      puts "'#{e.paths[0]}'(File) is removed."
    elsif e.kind.remove.folder?
      puts "'#{e.paths[0]}'(Folder) is removed."
    end
  end
end

sleep

CAUTION The watchcat doesn't normalize the events. So the result might change per the platform.

Options

Name Description Default
recursive Watch a directory recursively or not. true
force_polling Force to use a polling to watch. false
debounce Debounce events for the same file. -1

Filters Option

You can use the filters option to ignore specific event types:

Key Description
ignore_remove Ignore remove (delete) events
ignore_access Ignore access events
ignore_create Ignore create events
ignore_modify Ignore modify events

Example usage:

Watchcat.watch("/tmp/test", filters: { ignore_remove: true, ignore_access: true }) do |e|
  pp e.paths, e.kind
end

Pattern Options

You can use the patterns, ignore_patterns, and ignore_directories options to filter events by path or type, using File.fnmatch glob patterns:

Name Description Default
patterns Only dispatch events where at least one path matches one of the patterns []
ignore_patterns Skip events where at least one path matches one of the patterns []
ignore_directories Skip events for directories false

CAUTION For access/modify/rename events, notify doesn't tell whether the path is a file or a directory, so ignore_directories falls back to a live File.directory? check on the path (best-effort; e.g. it can't tell for a path that no longer exists).

Example usage:

Watchcat.watch(
  "/tmp/test",
  patterns: ["*.rb", "*.yml"],
  ignore_patterns: ["*.tmp"],
  ignore_directories: true
) do |e|
  pp e.paths, e.kind
end

Move (Rename) Events

For move/rename events (e.kind.modify? and e.kind.modify.rename?), Watchcat::Event#src_path and #dest_path give the old and new path without having to interpret the raw paths array and RenameMode yourself:

Watchcat.watch("/tmp/test") do |e|
  if e.kind.modify? && e.kind.modify.rename?
    puts "moved: #{e.src_path} -> #{e.dest_path}"
  end
end

Platform differences affect what is available:

  • Linux: a both event fires with both paths, so src_path and dest_path are both set.
  • Windows: from and to fire as separate events, each with only one side set (src_path on from, dest_path on to).
  • macOS: FSEvents can't distinguish old/new paths, so both src_path and dest_path are nil.

For non-rename events, both accessors return nil.

Event Handler

Instead of writing a single block and branching on event.kind yourself, you can subclass Watchcat::EventHandler and override just the callbacks you need:

class MyHandler < Watchcat::EventHandler
  def on_create(event)
    puts "created: #{event.paths[0]}"
  end

  def on_rename(event)
    puts "moved: #{event.src_path} -> #{event.dest_path}"
  end
end

Watchcat.watch("/tmp/test", handler: MyHandler.new)
sleep

Pass an instance via the handler: keyword instead of a block. Watchcat::EventHandler provides the following no-op callbacks to override:

Callback Description
on_any_event Called for every event, before the type-specific callback
on_create Called for create events
on_modify Called for modify events (excluding renames)
on_remove Called for remove events
on_rename Called for rename/move events (src_path/dest_path available)
on_access Called for access events

Dynamically Adding / Removing Paths

The watcher returned by Watchcat.watch can have paths added or removed while it's running:

w = Watchcat.watch("/tmp/a") { |e| pp e.paths, e.kind }

w.watch("/tmp/b")                    # also watch /tmp/b
w.watch("/tmp/c", recursive: false)  # non-recursive
w.unwatch("/tmp/a")                  # stop watching /tmp/a
w.watched                            # => current watched paths

sleep

All watched paths share the single callback/handler passed to Watchcat.watch (and the same filters/patterns/debounce settings). recursive: on watch defaults to the value passed to Watchcat.watch. watch raises ArgumentError immediately if a path does not exist. Applying unwatch is asynchronous, so its exact timing (and behavior) can differ per platform, notably on macOS (FSEvents). Both watch and unwatch accept a single path or an array of paths.

CLI

watchcat comes with a command-line interface that allows you to watch files and execute commands when changes occur.

Usage

# Run watchcat with a config file
$ watchcat -C config.yml

# Generate a template config file
$ watchcat --init config.yml

Configuration File

The configuration file should be in YAML format. Here's an example:

watches:
  - path: "./lib"
    recursive: true
    debounce: 300
    filters:
      ignore_access: true
    patterns:
      - "*.rb"
      - "*.yml"
    actions:
      - command: "echo 'Ruby/YAML file changed: {{file_name}}'"
      - command: "rubocop {{file_path}}"

Configuration Options

Each watch entry supports the following options:

Option Description Default
path Directory or file path to watch (required) -
recursive Watch a directory recursively or not true
debounce Debounce events for the same file (in milliseconds) -1
filters Event filters (same as library filters option) {}
patterns File patterns to match (using File.fnmatch) []
actions Commands to execute when files change []

Available Variables for Commands

When specifying commands, you can use the following variables:

Variable Description Example
{file_path} Full path of the changed file /home/user/app/file.rb
{file_dir} Directory containing the file /home/user/app
{file_name} File name with extension file.rb
{file_base} File name without extension file
{file_ext} File extension .rb
{event_type} Type of event create

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/y-yagi/watchcat.

License

The gem is available as open source under the terms of the MIT License.