Watchly

repocard

Watchly is a lightweight, dependency-free, polling-based file watcher for Ruby. It watches one or more files, directories, or glob patterns and reports file changes in a simple, predictable way.

Watchly is designed around batch semantics: when files change, a single callback is triggered with a complete description of what changed.

An official command-line interface is available via the watchly-cli gem.


Installation

gem install watchly

Or in your Gemfile:

gem 'watchly'

Usage

Initialize a watcher

require 'watchly'

# with the default interval (1 second)
watcher = Watchly::Watcher.new '**/*'

# with a different interval
watcher = Watchly::Watcher.new '**/*', interval: 2.0

# watch a directory recursively
watcher = Watchly::Watcher.new 'src'

# with multiple targets
watcher = Watchly::Watcher.new 'spec/**/*.rb', 'lib/*.*'

Directory targets are watched recursively. Like Ruby's default glob behavior, implicit directory expansion excludes dotfiles. Dotfiles can still be watched when passed as literal files or matched by an explicit glob pattern.


Watch for changes

When changes are detected, Watchly yields a Changeset object describing all filesystem changes since the last poll.

watcher.on_change do |changes|
  changes.each do |type, path|
    puts "#{type}: #{path}"
  end
end

This will output events such as:

added: spec/new_spec.rb
modified: lib/watchly.rb
removed: tmp/debug.log

Accessing specific change types

The changes object exposes separate collections for each type of change:

  • changes.added
  • changes.removed
  • changes.modified

Example:

watcher.on_change do |changes|
  puts "Added files:"
  changes.added.each { |path| puts "  + #{path}" }

  puts "Modified files:"
  changes.modified.each { |path| puts "  * #{path}" }

  puts "Removed files:"
  changes.removed.each { |path| puts "  - #{path}" }
end

Working with actionable files

In many cases, you only want to work with files that currently exist.

For this purpose, changes.files provides a convenient, pre-filtered list of actionable files:

  • Includes: added and modified files
  • Excludes: removed files
watcher.on_change do |changes|
  next if changes.empty?

  puts "Processing files:"
  changes.files.each do |path|
    puts "  - #{path}"
  end
end

A changeset that only contains removed files is considered empty. This prevents unnecessary processing when files are deleted.


Checking for changes

You can test whether a changeset contains any actionable files:

watcher.on_change do |changes|
  if changes.any?
    puts "Files changed:"
    changes.files.each { |path| puts "  - #{path}" }
  else
    puts "Files were removed:"
    changes.removed.each { |path| puts "  - #{path}" }
  end
end

Or explicitly:

changes.empty? # => true if no added or modified files
changes.any?   # => true if at least one actionable file exists

The on_change callback is triggered for any filesystem change, including deletions. The any? and empty? helpers reflect whether there are actionable files (added or modified).


Stopping the watcher

# Mainly useful for tests, but can be called from another thread
watcher.stop

Contributing / Support

If you experience any issue, have a question, or wish to contribute, feel free to open an issue.