watchcat 0.2.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,52 @@
1
+ require "optparse"
2
+ require_relative "cli/watcher"
3
+ require_relative "cli/config"
4
+ require_relative "cli/action_executor"
5
+
6
+ module Watchcat
7
+ module CLI
8
+ class Error < StandardError; end
9
+ class << self
10
+ def start(argv)
11
+ options = parse(argv)
12
+
13
+ if options[:init]
14
+ Config.generate_template(options[:init])
15
+ return
16
+ end
17
+
18
+ config = Config.load(options[:config])
19
+ watcher = Watcher.new(config)
20
+ watcher.start
21
+ rescue => e
22
+ raise Error, "Failed to start Watchcat: #{e.message}"
23
+ end
24
+
25
+ def parse(argv)
26
+ options = { config: 'watchcat.yml' }
27
+ OptionParser.new do |opts|
28
+ opts.banner = "Usage: watchcat [options]"
29
+
30
+ opts.on("-C", "--config PATH", "Path to the config file. Default is 'watchcat.yml'.") do |v|
31
+ options[:config] = v
32
+ end
33
+
34
+ opts.on("--init PATH", "Generate a template config file at the specified path") do |v|
35
+ options[:init] = v
36
+ end
37
+
38
+ opts.on("-h", "--help", "Show this help message") do
39
+ puts opts
40
+ exit
41
+ end
42
+ end.parse!(argv)
43
+
44
+ if !options[:init] && options[:config].nil?
45
+ raise OptionParser::MissingArgument.new("-C")
46
+ end
47
+
48
+ options
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,41 @@
1
+ module Watchcat
2
+ class Debouncer
3
+ def initialize
4
+ @timers = {}
5
+ @mutex = Mutex.new
6
+ end
7
+
8
+ def debounce(key, delay_ms, &block)
9
+ @mutex.synchronize do
10
+ # Cancel existing timer for this key
11
+ if @timers[key]
12
+ @timers[key].kill
13
+ end
14
+
15
+ # Create new timer
16
+ @timers[key] = Thread.new do
17
+ sleep(delay_ms / 1000.0) # Convert ms to seconds
18
+
19
+ @mutex.synchronize do
20
+ @timers.delete(key)
21
+ end
22
+
23
+ block.call
24
+ end
25
+ end
26
+ end
27
+
28
+ def clear
29
+ @mutex.synchronize do
30
+ @timers.each_value(&:kill)
31
+ @timers.clear
32
+ end
33
+ end
34
+
35
+ def pending_count
36
+ @mutex.synchronize do
37
+ @timers.size
38
+ end
39
+ end
40
+ end
41
+ end
@@ -14,8 +14,45 @@ module Watchcat
14
14
  { paths: @paths, event: @event }
15
15
  end
16
16
 
17
+ def directory?
18
+ if kind.create?
19
+ kind.create.folder?
20
+ elsif kind.remove?
21
+ kind.remove.folder?
22
+ elsif kind.any?
23
+ kind.any.folder?
24
+ else
25
+ File.directory?(@paths.first)
26
+ end
27
+ rescue
28
+ false
29
+ end
30
+
31
+ def src_path
32
+ return nil unless rename_event?
33
+
34
+ rename = kind.modify.rename
35
+ return @paths[0] if rename.both? || rename.from?
36
+
37
+ nil
38
+ end
39
+
40
+ def dest_path
41
+ return nil unless rename_event?
42
+
43
+ rename = kind.modify.rename
44
+ return @paths[1] if rename.both?
45
+ return @paths[0] if rename.to?
46
+
47
+ nil
48
+ end
49
+
17
50
  private
18
51
 
52
+ def rename_event?
53
+ kind.modify? && kind.modify.rename?
54
+ end
55
+
19
56
  def build_kind(kinds)
20
57
  @kind = Watchcat::EventKind.new
21
58
  @event = kinds.shift
@@ -0,0 +1,29 @@
1
+ module Watchcat
2
+ class EventHandler
3
+ def dispatch(event)
4
+ on_any_event(event)
5
+
6
+ kind = event.kind
7
+ if kind.create?
8
+ on_create(event)
9
+ elsif kind.remove?
10
+ on_remove(event)
11
+ elsif kind.modify?
12
+ if kind.modify.rename?
13
+ on_rename(event)
14
+ else
15
+ on_modify(event)
16
+ end
17
+ elsif kind.access?
18
+ on_access(event)
19
+ end
20
+ end
21
+
22
+ def on_any_event(event); end
23
+ def on_create(event); end
24
+ def on_modify(event); end
25
+ def on_remove(event); end
26
+ def on_rename(event); end
27
+ def on_access(event); end
28
+ end
29
+ end
@@ -1,64 +1,114 @@
1
- require "drb"
2
- require "drb/unix"
3
- require_relative "server"
4
- require_relative "client"
1
+ require_relative "event"
5
2
 
6
3
  module Watchcat
7
4
  class Executor
8
- def initialize(paths, recursive:, force_polling:, poll_interval:, wait_until_startup:, ignore_remove:, debounce:, block:)
9
- @service = nil
10
- @child_pid = nil
5
+ def initialize(paths, recursive:, force_polling:, poll_interval:, filters:, debounce:, block:, patterns: [], ignore_patterns: [], ignore_directories: false)
11
6
  @paths = paths
12
7
  @recursive = recursive
13
8
  @force_polling = force_polling
14
9
  @poll_interval = poll_interval
15
- @wait_until_startup = wait_until_startup
16
- @ignore_remove = ignore_remove
10
+ @filters = filters || {}
17
11
  @debounce = debounce
12
+ @debouncer = Debouncer.new if @debounce > 0
13
+ @patterns = Array(patterns)
14
+ @ignore_patterns = Array(ignore_patterns)
15
+ @ignore_directories = ignore_directories
18
16
  @block = block
17
+ @watcher = Watchcat::Watcher.new
18
+ @watch_thread = nil
19
+ @stop_requested = false
20
+ @owner_pid = nil
21
+ @stopped = false
19
22
  end
20
23
 
21
24
  def start
22
- server = Server.new(@block)
23
- @service = DRb.start_service("drbunix:", server)
24
- client = nil
25
- client = build_client if @wait_until_startup
26
-
27
- @child_pid = fork do
28
- client = build_client unless @wait_until_startup
29
- Process.setproctitle("watchcat: watcher")
30
- client.run
25
+ @owner_pid = Process.pid
26
+
27
+ # Always start watching in a background thread to avoid blocking
28
+ @watch_thread = Thread.new do
29
+ Thread.current.name = "watchcat-watcher"
30
+ start_watching
31
31
  end
32
32
 
33
- main = Process.pid
34
33
  at_exit do
35
- @exit_status = $!.status if $!.is_a?(SystemExit)
36
- stop if Process.pid == main
37
- exit @exit_status if @exit_status
34
+ stop
38
35
  end
39
36
  end
40
37
 
41
38
  def stop
42
- begin
43
- Process.kill(:KILL, @child_pid)
44
- rescue Errno::ESRCH
45
- # NOTE: We can ignore this error because there process is already dead.
39
+ # A forked child inherits the at_exit hook without inheriting the watcher
40
+ # thread, so it would call #close -- which sends on a channel, and a send
41
+ # can allocate. If the watcher thread held the allocator lock at fork
42
+ # time, that lock is never released in the child.
43
+ return if @owner_pid != Process.pid
44
+
45
+ return if @stopped
46
+ @stopped = true
47
+
48
+ @stop_requested = true
49
+ @watcher.close
50
+ if @watch_thread && @watch_thread.alive?
51
+ @watch_thread.join(1) # Wait up to 1 second for thread to finish
46
52
  end
47
- @service.stop_service
53
+ end
54
+
55
+ def watch(paths, recursive: @recursive)
56
+ paths = Array(paths)
57
+ paths.each { |p| raise ArgumentError, "path does not exist: #{p}" unless File.exist?(p) }
58
+ @watcher.add(paths, recursive: recursive)
59
+ @paths |= paths
60
+ self
61
+ end
62
+
63
+ def unwatch(paths)
64
+ paths = Array(paths)
65
+ @watcher.unwatch(paths)
66
+ @paths -= paths
67
+ self
68
+ end
69
+
70
+ def watched
71
+ @paths.dup
48
72
  end
49
73
 
50
74
  private
51
75
 
52
- def build_client
53
- Client.new(
54
- @service.uri,
55
- paths: @paths,
76
+ def start_watching
77
+ @watcher.watch(
78
+ @paths,
56
79
  recursive: @recursive,
57
80
  force_polling: @force_polling,
58
81
  poll_interval: @poll_interval,
59
- debounce: @debounce,
60
- ignore_remove: @ignore_remove
61
- )
82
+ ignore_remove: @filters[:ignore_remove],
83
+ ignore_access: @filters[:ignore_access],
84
+ ignore_create: @filters[:ignore_create],
85
+ ignore_modify: @filters[:ignore_modify]
86
+ ) do |kind, paths, raw_kind|
87
+ next if @stop_requested
88
+
89
+ event = Watchcat::Event.new(kind, paths, raw_kind)
90
+ next unless dispatch?(event)
91
+
92
+ if @debounce > 0 && paths.size == 1
93
+ @debouncer.debounce(paths[0], @debounce) { @block.call(event) }
94
+ else
95
+ @block.call(event)
96
+ end
97
+ end
98
+ end
99
+
100
+ def dispatch?(event)
101
+ return false if @ignore_directories && event.directory?
102
+ return false if @patterns.any? && !matches_any_pattern?(event.paths, @patterns)
103
+ return false if @ignore_patterns.any? && matches_any_pattern?(event.paths, @ignore_patterns)
104
+
105
+ true
106
+ end
107
+
108
+ def matches_any_pattern?(paths, patterns)
109
+ paths.any? do |path|
110
+ patterns.any? { |pattern| File.fnmatch?(pattern, File.basename(path)) || File.fnmatch?(pattern, path) }
111
+ end
62
112
  end
63
113
  end
64
114
  end
data/lib/watchcat/kind.rb CHANGED
@@ -27,6 +27,14 @@ module Watchcat
27
27
  def any?
28
28
  !@any.nil?
29
29
  end
30
+
31
+ def event_type
32
+ return "create" if create?
33
+ return "modify" if modify?
34
+ return "remove" if remove?
35
+ return "access" if access?
36
+ "unknown"
37
+ end
30
38
  end
31
39
 
32
40
  class AccessKind
@@ -1,3 +1,3 @@
1
1
  module Watchcat
2
- VERSION = "0.2.0"
2
+ VERSION = "0.6.0"
3
3
  end
data/lib/watchcat.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  require_relative "watchcat/version"
2
2
  require_relative "watchcat/executor"
3
+ require_relative "watchcat/debouncer"
4
+ require_relative "watchcat/event_handler"
3
5
 
4
6
  begin
5
7
  require "watchcat/#{RUBY_VERSION.to_f}/watchcat"
@@ -14,21 +16,29 @@ module Watchcat
14
16
  recursive: true,
15
17
  force_polling: false,
16
18
  poll_interval: nil,
17
- wait_until_startup: false,
18
- ignore_remove: false,
19
+ filters: {},
19
20
  debounce: -1,
21
+ patterns: [],
22
+ ignore_patterns: [],
23
+ ignore_directories: false,
24
+ handler: nil,
20
25
  &block
21
26
  )
27
+ callback = block || (handler && handler.method(:dispatch))
28
+ raise ArgumentError, "must provide a block or a handler:" unless callback
29
+
22
30
  w =
23
31
  Watchcat::Executor.new(
24
32
  Array(paths),
25
33
  recursive: recursive,
26
34
  force_polling: force_polling,
27
35
  poll_interval: poll_interval,
28
- wait_until_startup: wait_until_startup,
29
- ignore_remove: ignore_remove,
36
+ filters: filters,
30
37
  debounce: debounce,
31
- block: block
38
+ patterns: patterns,
39
+ ignore_patterns: ignore_patterns,
40
+ ignore_directories: ignore_directories,
41
+ block: callback
32
42
  )
33
43
  w.start
34
44
  w
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: watchcat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yuji Yaginuma
8
- autorequire:
9
- bindir: bin
8
+ autorequire:
9
+ bindir: exe
10
10
  cert_chain: []
11
- date: 2024-11-20 00:00:00.000000000 Z
11
+ date: 2026-08-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys
@@ -25,7 +25,7 @@ dependencies:
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0'
27
27
  - !ruby/object:Gem::Dependency
28
- name: drb
28
+ name: psych
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - ">="
@@ -80,6 +80,20 @@ dependencies:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
82
  version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: minitest-fail-fast
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
83
97
  - !ruby/object:Gem::Dependency
84
98
  name: rake
85
99
  requirement: !ruby/object:Gem::Requirement
@@ -136,42 +150,49 @@ dependencies:
136
150
  - - ">="
137
151
  - !ruby/object:Gem::Version
138
152
  version: '0'
139
- description:
153
+ description:
140
154
  email:
141
155
  - yuuji.yaginuma@gmail.com
142
- executables: []
156
+ executables:
157
+ - watchcat
143
158
  extensions:
144
159
  - ext/watchcat/extconf.rb
145
160
  extra_rdoc_files: []
146
161
  files:
162
+ - CHANGELOG.md
147
163
  - Cargo.lock
148
164
  - Cargo.toml
149
165
  - Gemfile
150
- - Gemfile.lock
151
166
  - LICENSE.txt
152
167
  - README.md
153
168
  - Rakefile
169
+ - cli_example.yml
170
+ - exe/watchcat
154
171
  - ext/watchcat/.gitignore
155
172
  - ext/watchcat/Cargo.lock
156
173
  - ext/watchcat/Cargo.toml
157
174
  - ext/watchcat/extconf.rb
158
175
  - ext/watchcat/src/event.rs
176
+ - ext/watchcat/src/gvl_helpers.rs
159
177
  - ext/watchcat/src/lib.rs
160
178
  - lib/watchcat.rb
161
- - lib/watchcat/client.rb
179
+ - lib/watchcat/cli.rb
180
+ - lib/watchcat/cli/action_executor.rb
181
+ - lib/watchcat/cli/config.rb
182
+ - lib/watchcat/cli/watcher.rb
183
+ - lib/watchcat/debouncer.rb
162
184
  - lib/watchcat/event.rb
185
+ - lib/watchcat/event_handler.rb
163
186
  - lib/watchcat/executor.rb
164
187
  - lib/watchcat/kind.rb
165
- - lib/watchcat/server.rb
166
188
  - lib/watchcat/version.rb
167
- - watchcat.gemspec
168
189
  homepage: https://github.com/y-yagi/watchcat
169
190
  licenses:
170
191
  - MIT
171
192
  metadata:
172
193
  homepage_uri: https://github.com/y-yagi/watchcat
173
194
  rubygems_mfa_required: 'true'
174
- post_install_message:
195
+ post_install_message:
175
196
  rdoc_options: []
176
197
  require_paths:
177
198
  - lib
@@ -179,15 +200,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
179
200
  requirements:
180
201
  - - ">="
181
202
  - !ruby/object:Gem::Version
182
- version: 3.0.0
203
+ version: 3.1.0
183
204
  required_rubygems_version: !ruby/object:Gem::Requirement
184
205
  requirements:
185
206
  - - ">="
186
207
  - !ruby/object:Gem::Version
187
208
  version: '0'
188
209
  requirements: []
189
- rubygems_version: 3.5.19
190
- signing_key:
210
+ rubygems_version: 3.5.22
211
+ signing_key:
191
212
  specification_version: 4
192
213
  summary: Simple filesystem notification library for Ruby.
193
214
  test_files: []
data/Gemfile.lock DELETED
@@ -1,70 +0,0 @@
1
- PATH
2
- remote: .
3
- specs:
4
- watchcat (0.2.0)
5
- drb
6
- rb_sys
7
-
8
- GEM
9
- remote: https://rubygems.org/
10
- specs:
11
- debug (1.9.2)
12
- irb (~> 1.10)
13
- reline (>= 0.3.8)
14
- drb (2.2.1)
15
- ffi (1.17.0-arm64-darwin)
16
- ffi (1.17.0-x86_64-darwin)
17
- ffi (1.17.0-x86_64-linux-gnu)
18
- io-console (0.7.2)
19
- irb (1.14.0)
20
- rdoc (>= 4.0.0)
21
- reline (>= 0.4.2)
22
- listen (3.9.0)
23
- rb-fsevent (~> 0.10, >= 0.10.3)
24
- rb-inotify (~> 0.9, >= 0.9.10)
25
- minitest (5.25.1)
26
- minitest-retry (0.2.3)
27
- minitest (>= 5.0)
28
- nokogiri (1.16.7-arm64-darwin)
29
- racc (~> 1.4)
30
- nokogiri (1.16.7-x86_64-darwin)
31
- racc (~> 1.4)
32
- nokogiri (1.16.7-x86_64-linux)
33
- racc (~> 1.4)
34
- psych (5.1.2)
35
- stringio
36
- racc (1.8.1)
37
- rake (13.2.1)
38
- rake-compiler (1.2.8)
39
- rake
40
- rb-fsevent (0.11.2)
41
- rb-inotify (0.11.1)
42
- ffi (~> 1.0)
43
- rb_sys (0.9.102)
44
- rdoc (6.7.0)
45
- psych (>= 4.0.0)
46
- reline (0.5.10)
47
- io-console (~> 0.5)
48
- ruby_memcheck (3.0.0)
49
- nokogiri
50
- stringio (3.1.1)
51
-
52
- PLATFORMS
53
- arm64-darwin-22
54
- arm64-darwin-23
55
- x86_64-darwin-19
56
- x86_64-darwin-20
57
- x86_64-linux
58
-
59
- DEPENDENCIES
60
- debug
61
- listen
62
- minitest
63
- minitest-retry
64
- rake
65
- rake-compiler
66
- ruby_memcheck
67
- watchcat!
68
-
69
- BUNDLED WITH
70
- 2.5.4
@@ -1,26 +0,0 @@
1
- module Watchcat
2
- class Client
3
- def initialize(uri, paths:, recursive:, force_polling:, poll_interval:, ignore_remove:, debounce:)
4
- DRb.start_service
5
- @watcher = Watchcat::Watcher.new
6
- @server = DRbObject.new_with_uri(uri)
7
- @paths = paths
8
- @recursive = recursive
9
- @force_polling = force_polling
10
- @poll_interval = poll_interval
11
- @ignore_remove = ignore_remove
12
- @debounce = debounce
13
- end
14
-
15
- def run
16
- @watcher.watch(
17
- @paths,
18
- recursive: @recursive,
19
- force_polling: @force_polling,
20
- poll_interval: @poll_interval,
21
- ignore_remove: @ignore_remove,
22
- debounce: @debounce
23
- ) { |notification| @server.execute(notification) }
24
- end
25
- end
26
- end
@@ -1,14 +0,0 @@
1
- require "watchcat/event"
2
-
3
- module Watchcat
4
- class Server
5
- def initialize(block)
6
- @block = block
7
- end
8
-
9
- def execute(notification)
10
- event = Watchcat::Event.new(notification[0], notification[1], notification[2])
11
- @block.call(event)
12
- end
13
- end
14
- end
data/watchcat.gemspec DELETED
@@ -1,38 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/watchcat/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "watchcat"
7
- spec.version = Watchcat::VERSION
8
- spec.authors = ["Yuji Yaginuma"]
9
- spec.email = ["yuuji.yaginuma@gmail.com"]
10
-
11
- spec.summary = "Simple filesystem notification library for Ruby. "
12
- spec.homepage = "https://github.com/y-yagi/watchcat"
13
- spec.license = "MIT"
14
- spec.required_ruby_version = ">= 3.0.0"
15
-
16
- spec.metadata["homepage_uri"] = spec.homepage
17
- spec.metadata["rubygems_mfa_required"] = "true"
18
-
19
- # Specify which files should be added to the gem when it is released.
20
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
21
- spec.files = Dir.chdir(__dir__) do
22
- `git ls-files -z`.split("\x0").reject do |f|
23
- (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features|benchmark)/|\.(?:git|travis|circleci)|appveyor)})
24
- end
25
- end
26
- spec.require_paths = ["lib"]
27
- spec.extensions = ["ext/watchcat/extconf.rb"]
28
-
29
- spec.add_dependency "rb_sys"
30
- spec.add_dependency "drb"
31
- spec.add_development_dependency "debug"
32
- spec.add_development_dependency "minitest"
33
- spec.add_development_dependency "minitest-retry"
34
- spec.add_development_dependency "rake"
35
- spec.add_development_dependency "rake-compiler"
36
- spec.add_development_dependency "ruby_memcheck"
37
- spec.add_development_dependency "listen"
38
- end