├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── lib └── spring │ └── watcher │ └── listen.rb ├── spring-watcher-listen.gemspec └── test ├── acceptance_test.rb ├── helper.rb └── unit_test.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | tests: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | ruby: [ '2.7', '3.0', '3.1', 'head' ] 10 | rails: [ '6.0', '6.1', '7.0', 'edge' ] 11 | exclude: 12 | - ruby: '3.1' 13 | rails: '6.0' 14 | - ruby: '3.1' 15 | rails: '6.1' 16 | 17 | env: 18 | RAILS_VERSION: ${{ matrix.rails }} 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | 23 | - name: Set up Ruby 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | bundler-cache: true 28 | 29 | - name: Run unit tests 30 | run: bundle exec rake test:unit 31 | timeout-minutes: 3 32 | 33 | - name: Run acceptance tests 34 | run: bundle exec rake test:acceptance 35 | timeout-minutes: 10 36 | if: ${{ matrix.rails != 'edge' && matrix.ruby != 'head' }} # Acceptance tests use `gem install rails && rails new` 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | *.bundle 19 | *.so 20 | *.o 21 | *.a 22 | mkmf.log 23 | test/apps 24 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | # Specify your gem's dependencies in spring-watcher-listen.gemspec 5 | gemspec 6 | 7 | if ENV["RAILS_VERSION"] == "edge" 8 | gem "activesupport", github: "rails/rails", branch: "main" 9 | elsif ENV["RAILS_VERSION"] 10 | gem "activesupport", "~> #{ENV["RAILS_VERSION"]}.0" 11 | end 12 | 13 | gem "spring", github: "rails/spring", branch: "main" 14 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Jon Leighton 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Listen watcher for Spring 2 | 3 | [![Build Status](https://app.travis-ci.com/rails/spring-watcher-listen.svg?branch=master)](https://app.travis-ci.com/github/rails/spring-watcher-listen) 4 | [![Gem Version](https://badge.fury.io/rb/spring-watcher-listen.png)](http://badge.fury.io/rb/spring-watcher-listen) 5 | 6 | This gem makes [Spring](https://github.com/rails/spring) watch the 7 | filesystem for changes using [Listen](https://github.com/guard/listen) 8 | rather than by polling the filesystem. 9 | 10 | On larger projects this means spring will be more responsive, more accurate and use less cpu on local filesystems. 11 | 12 | (NFS, shared VM folders and user file systems will still need polling) 13 | 14 | Listen 2.7 and higher and 3.0 are supported. 15 | If you rely on Listen 1 you can use v1.0.0 of this gem. 16 | 17 | ## Environment variables 18 | 19 | * `DISABLE_SPRING_WATCHER_LISTEN` - If set, this disables the loading of this gem. This can be useful for projects where 20 | some configurations do not support inotify (e.g. Docker on M1 Macs). 21 | 22 | ## Installation 23 | 24 | Stop Spring if it's already running: 25 | 26 | $ spring stop 27 | 28 | Add this line to your application's Gemfile: 29 | 30 | gem 'spring-watcher-listen', group: :development 31 | 32 | And then execute: 33 | 34 | $ bundle 35 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | namespace :test do 5 | Rake::TestTask.new(:unit) do |t| 6 | t.libs << "test" 7 | t.test_files = FileList["test/unit_test.rb"] 8 | t.verbose = true 9 | end 10 | 11 | Rake::TestTask.new(:acceptance) do |t| 12 | t.libs << "test" 13 | t.test_files = FileList["test/acceptance_test.rb"] 14 | t.verbose = true 15 | end 16 | end 17 | 18 | desc 'Run tests' 19 | task test: ['test:unit', 'test:acceptance'] 20 | 21 | task default: :test 22 | -------------------------------------------------------------------------------- /lib/spring/watcher/listen.rb: -------------------------------------------------------------------------------- 1 | return if ENV['DISABLE_SPRING_WATCHER_LISTEN'] 2 | 3 | require "spring/watcher" 4 | require "spring/watcher/abstract" 5 | 6 | require "listen" 7 | require "listen/version" 8 | 9 | if defined?(Celluloid) 10 | # fork() doesn't preserve threads, so a clean 11 | # Celluloid shutdown isn't possible, but we can 12 | # reduce the 10 second timeout 13 | 14 | # There's a patch for Celluloid to avoid this (search for 'fork' in Celluloid 15 | # issues) 16 | Celluloid.shutdown_timeout = 2 17 | end 18 | 19 | module Spring 20 | module Watcher 21 | class Listen < Abstract 22 | Spring.watch_method = self 23 | 24 | attr_reader :listener 25 | 26 | def initialize(*) 27 | super 28 | @listener = nil 29 | end 30 | 31 | def start 32 | return if @listener 33 | 34 | @listener = ::Listen.to(*base_directories, latency: latency, &method(:changed)) 35 | @listener.start 36 | end 37 | 38 | def stop 39 | if @listener 40 | @listener.stop 41 | @listener = nil 42 | end 43 | end 44 | 45 | def running? 46 | @listener && @listener.processing? 47 | end 48 | 49 | def subjects_changed 50 | return unless @listener 51 | return unless @listener.respond_to?(:directories) 52 | return unless @listener.directories.sort != base_directories.sort 53 | restart 54 | end 55 | 56 | def watching?(file) 57 | files.include?(file) || file.start_with?(*directories.keys) 58 | end 59 | 60 | def changed(modified, added, removed) 61 | synchronize do 62 | if (modified + added + removed).any? { |f| watching? f } 63 | mark_stale 64 | end 65 | end 66 | end 67 | 68 | def mark_stale 69 | super 70 | 71 | # May be called from listen thread which won't be happy 72 | # about stopping itself, so stop from another thread. 73 | Thread.new { stop }.join 74 | end 75 | 76 | def base_directories 77 | ([root] + 78 | files.keys.reject { |f| f.start_with? "#{root}/" }.map { |f| File.expand_path("#{f}/..") } + 79 | directories.keys.reject { |d| d.start_with? "#{root}/" } 80 | ).uniq.map { |path| Pathname.new(path) } 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spring-watcher-listen.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "spring-watcher-listen" 5 | spec.version = "2.1.0" 6 | spec.authors = ["Jon Leighton"] 7 | spec.email = ["j@jonathanleighton.com"] 8 | spec.summary = %q{Makes spring watch files using the listen gem.} 9 | spec.homepage = "https://github.com/jonleighton/spring-watcher-listen" 10 | spec.license = "MIT" 11 | 12 | spec.files = `git ls-files -z`.split("\x0") 13 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 14 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 15 | spec.require_paths = ["lib"] 16 | 17 | spec.add_development_dependency "bundler", "~> 2.0" 18 | spec.add_development_dependency "rake" 19 | spec.add_development_dependency "activesupport" 20 | 21 | spec.add_dependency "spring", ">= 4" 22 | spec.add_dependency "listen", ">= 2.7", '< 4.0' 23 | end 24 | -------------------------------------------------------------------------------- /test/acceptance_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class AcceptanceTest < Spring::Test::AcceptanceTest 4 | class ApplicationGenerator < Spring::Test::ApplicationGenerator 5 | def generate_files 6 | super 7 | File.write(application.gemfile, "#{application.gemfile.read}gem 'spring-watcher-listen'\n") 8 | end 9 | 10 | def manually_built_gems 11 | super + %w(spring-watcher-listen) 12 | end 13 | end 14 | 15 | def generator_klass 16 | ApplicationGenerator 17 | end 18 | 19 | test "uses the listen watcher" do 20 | assert_success "bin/rails runner 'puts Spring.watcher.class'", stdout: "Spring::Watcher::Listen" 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__) 2 | 3 | require "bundler/setup" 4 | require File.dirname(Gem::Specification.find_by_name("spring").loaded_from) + "/test/support/test" 5 | require "minitest/autorun" 6 | 7 | if defined?(Celluloid) 8 | require "celluloid/test" 9 | Celluloid.logger.level = Logger::WARN 10 | end 11 | 12 | Spring::Test.root = File.expand_path('..', __FILE__) 13 | -------------------------------------------------------------------------------- /test/unit_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "spring/watcher/listen" 3 | 4 | class ListenWatcherTest < Spring::Test::WatcherTest 5 | def watcher_class 6 | Spring::Watcher::Listen 7 | end 8 | 9 | # this test, as currently written, can only run against polling implementations 10 | undef :test_add_directory_with_dangling_symlink 11 | 12 | setup do 13 | Celluloid.boot if defined?(Celluloid) 14 | end 15 | 16 | teardown { Listen.stop } 17 | 18 | test "root directories" do 19 | begin 20 | other_dir_1 = File.realpath(Dir.mktmpdir) 21 | other_dir_2 = File.realpath(Dir.mktmpdir) 22 | File.write("#{other_dir_1}/foo", "foo") 23 | File.write("#{dir}/foo", "foo") 24 | 25 | watcher.add "#{other_dir_1}/foo" 26 | watcher.add other_dir_2 27 | watcher.add "#{dir}/foo" 28 | 29 | dirs = [dir, other_dir_1, other_dir_2].sort.map { |path| Pathname.new(path) } 30 | assert_equal dirs, watcher.base_directories.sort 31 | ensure 32 | FileUtils.rm_rf other_dir_1 33 | FileUtils.rm_rf other_dir_2 34 | end 35 | end 36 | 37 | test "root directories with a root subpath directory" do 38 | begin 39 | other_dir_1 = "#{dir}_other" 40 | other_dir_2 = "#{dir}_core" 41 | # same subpath as dir but with _other or _core appended 42 | FileUtils::mkdir_p(other_dir_1) 43 | FileUtils::mkdir_p(other_dir_2) 44 | File.write("#{other_dir_1}/foo", "foo") 45 | File.write("#{other_dir_2}/foo", "foo") 46 | File.write("#{dir}/foo", "foo") 47 | 48 | watcher.add "#{other_dir_1}/foo" 49 | watcher.add other_dir_2 50 | 51 | dirs = [dir, other_dir_1, other_dir_2].sort.map { |path| Pathname.new(path) } 52 | assert_equal dirs, watcher.base_directories.sort 53 | ensure 54 | FileUtils.rm_rf other_dir_1 55 | FileUtils.rm_rf other_dir_2 56 | end 57 | end 58 | 59 | test "stops listening when already stale" do 60 | # Track when we're marked as stale. 61 | on_stale_count = 0 62 | watcher.on_stale { on_stale_count += 1 } 63 | 64 | # Add a file to watch and start listening. 65 | file = "#{@dir}/omg" 66 | touch file, Time.now - 2.seconds 67 | watcher.add file 68 | watcher.start 69 | assert watcher.running? 70 | 71 | # Touch bumps mtime and marks as stale which stops listener. 72 | touch file, Time.now - 1.second 73 | Timeout.timeout(1) { sleep 0.1 while watcher.running? } 74 | assert_equal 1, on_stale_count 75 | end 76 | end 77 | --------------------------------------------------------------------------------