listen 0.3.3 → 0.4.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.
- data/CHANGELOG.md +22 -0
- data/README.md +146 -29
- data/lib/listen.rb +22 -10
- data/lib/listen/adapter.rb +89 -50
- data/lib/listen/adapters/darwin.rb +43 -18
- data/lib/listen/adapters/linux.rb +48 -33
- data/lib/listen/adapters/polling.rb +20 -7
- data/lib/listen/adapters/windows.rb +38 -31
- data/lib/listen/directory_record.rb +254 -0
- data/lib/listen/listener.rb +59 -228
- data/lib/listen/multi_listener.rb +121 -0
- data/lib/listen/turnstile.rb +28 -0
- data/lib/listen/version.rb +1 -1
- metadata +16 -11
@@ -5,28 +5,49 @@ module Listen
|
|
5
5
|
#
|
6
6
|
class Darwin < Adapter
|
7
7
|
|
8
|
-
|
8
|
+
LAST_SEPARATOR_REGEX = /\/$/
|
9
|
+
|
10
|
+
# Initializes the Adapter. See {Listen::Adapter#initialize} for more info.
|
9
11
|
#
|
10
|
-
def initialize(
|
12
|
+
def initialize(directories, options = {}, &callback)
|
11
13
|
super
|
12
|
-
init_worker
|
14
|
+
@worker = init_worker
|
13
15
|
end
|
14
16
|
|
15
|
-
#
|
17
|
+
# Starts the adapter.
|
16
18
|
#
|
17
|
-
|
18
|
-
|
19
|
-
|
19
|
+
# @param [Boolean] blocking whether or not to block the current thread after starting
|
20
|
+
#
|
21
|
+
def start(blocking = true)
|
22
|
+
@mutex.synchronize do
|
23
|
+
return if @stop == false
|
24
|
+
super
|
25
|
+
end
|
26
|
+
|
27
|
+
@worker_thread = Thread.new { @worker.run }
|
28
|
+
@poll_thread = Thread.new { poll_changed_dirs }
|
29
|
+
|
30
|
+
# The FSEvent worker needs sometime to startup. Turnstiles can't
|
31
|
+
# be used to wait for it as it runs in a loop.
|
32
|
+
# TODO: Find a better way to block until the worker starts.
|
33
|
+
sleep @latency
|
34
|
+
@poll_thread.join if blocking
|
20
35
|
end
|
21
36
|
|
22
|
-
#
|
37
|
+
# Stops the adapter.
|
23
38
|
#
|
24
39
|
def stop
|
25
|
-
|
40
|
+
@mutex.synchronize do
|
41
|
+
return if @stop == true
|
42
|
+
super
|
43
|
+
end
|
44
|
+
|
26
45
|
@worker.stop
|
46
|
+
Thread.kill(@worker_thread) if @worker_thread
|
47
|
+
@poll_thread.join
|
27
48
|
end
|
28
49
|
|
29
|
-
#
|
50
|
+
# Checks if the adapter is usable on the current OS.
|
30
51
|
#
|
31
52
|
# @return [Boolean] whether usable or not
|
32
53
|
#
|
@@ -39,17 +60,21 @@ module Listen
|
|
39
60
|
false
|
40
61
|
end
|
41
62
|
|
42
|
-
|
63
|
+
private
|
43
64
|
|
44
|
-
#
|
65
|
+
# Initializes a FSEvent worker and adds a watcher for
|
66
|
+
# each directory passed to the adapter.
|
67
|
+
#
|
68
|
+
# @return [FSEvent] initialized worker
|
45
69
|
#
|
46
70
|
def init_worker
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
71
|
+
FSEvent.new.tap do |worker|
|
72
|
+
worker.watch(@directories.dup, :latency => @latency) do |changes|
|
73
|
+
next if @paused
|
74
|
+
@mutex.synchronize do
|
75
|
+
changes.each { |path| @changed_dirs << path.sub(LAST_SEPARATOR_REGEX, '') }
|
76
|
+
end
|
77
|
+
end
|
53
78
|
end
|
54
79
|
end
|
55
80
|
|
@@ -1,5 +1,3 @@
|
|
1
|
-
require 'set'
|
2
|
-
|
3
1
|
module Listen
|
4
2
|
module Adapters
|
5
3
|
|
@@ -14,27 +12,39 @@ module Listen
|
|
14
12
|
#
|
15
13
|
class Linux < Adapter
|
16
14
|
|
17
|
-
#
|
15
|
+
# Initializes the Adapter. See {Listen::Adapter#initialize} for more info.
|
18
16
|
#
|
19
|
-
def initialize(
|
17
|
+
def initialize(directories, options = {}, &callback)
|
20
18
|
super
|
21
|
-
@
|
22
|
-
init_worker
|
19
|
+
@worker = init_worker
|
23
20
|
end
|
24
21
|
|
25
|
-
#
|
22
|
+
# Starts the adapter.
|
26
23
|
#
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
24
|
+
# @param [Boolean] blocking whether or not to block the current thread after starting
|
25
|
+
#
|
26
|
+
def start(blocking = true)
|
27
|
+
@mutex.synchronize do
|
28
|
+
return if @stop == false
|
29
|
+
super
|
30
|
+
end
|
31
|
+
|
32
|
+
@worker_thread = Thread.new { @worker.run }
|
33
|
+
@poll_thread = Thread.new { poll_changed_dirs }
|
34
|
+
@poll_thread.join if blocking
|
31
35
|
end
|
32
36
|
|
33
|
-
#
|
37
|
+
# Stops the adapter.
|
34
38
|
#
|
35
39
|
def stop
|
36
|
-
|
40
|
+
@mutex.synchronize do
|
41
|
+
return if @stop == true
|
42
|
+
super
|
43
|
+
end
|
44
|
+
|
37
45
|
@worker.stop
|
46
|
+
Thread.kill(@worker_thread) if @worker_thread
|
47
|
+
@poll_thread.join
|
38
48
|
end
|
39
49
|
|
40
50
|
# Check if the adapter is usable on the current OS.
|
@@ -52,30 +62,35 @@ module Listen
|
|
52
62
|
|
53
63
|
private
|
54
64
|
|
55
|
-
#
|
65
|
+
# Initializes a INotify worker and adds a watcher for
|
66
|
+
# each directory passed to the adapter.
|
56
67
|
#
|
57
|
-
|
58
|
-
@worker = INotify::Notifier.new
|
59
|
-
@worker.watch(@directory, *EVENTS.map(&:to_sym)) do |event|
|
60
|
-
next if @paused
|
61
|
-
|
62
|
-
unless event.name == "" # Event on root directory
|
63
|
-
@changed_dirs << File.dirname(event.absolute_name)
|
64
|
-
end
|
65
|
-
end
|
66
|
-
end
|
67
|
-
|
68
|
-
# Polling around @changed_dirs presence.
|
68
|
+
# @return [INotify::Notifier] initialized worker
|
69
69
|
#
|
70
|
-
def
|
71
|
-
|
72
|
-
|
70
|
+
def init_worker
|
71
|
+
worker = INotify::Notifier.new
|
72
|
+
@directories.each do |directory|
|
73
|
+
worker.watch(directory, *EVENTS.map(&:to_sym)) do |event|
|
74
|
+
if @paused || (
|
75
|
+
# Event on root directory
|
76
|
+
event.name == ""
|
77
|
+
) || (
|
78
|
+
# INotify reports changes to files inside directories as events
|
79
|
+
# on the directories themselves too.
|
80
|
+
#
|
81
|
+
# @see http://linux.die.net/man/7/inotify
|
82
|
+
event.flags.include?(:isdir) and event.flags & [:close, :modify] != []
|
83
|
+
)
|
84
|
+
# Skip all of these!
|
85
|
+
next
|
86
|
+
end
|
73
87
|
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
88
|
+
@mutex.synchronize do
|
89
|
+
@changed_dirs << File.dirname(event.absolute_name)
|
90
|
+
end
|
91
|
+
end
|
78
92
|
end
|
93
|
+
worker
|
79
94
|
end
|
80
95
|
|
81
96
|
end
|
@@ -13,22 +13,34 @@ module Listen
|
|
13
13
|
|
14
14
|
# Initialize the Adapter. See {Listen::Adapter#initialize} for more info.
|
15
15
|
#
|
16
|
-
def initialize(
|
16
|
+
def initialize(directories, options = {}, &callback)
|
17
17
|
@latency ||= DEFAULT_POLLING_LATENCY
|
18
18
|
super
|
19
19
|
end
|
20
20
|
|
21
21
|
# Start the adapter.
|
22
22
|
#
|
23
|
-
|
24
|
-
|
25
|
-
|
23
|
+
# @param [Boolean] blocking whether or not to block the current thread after starting
|
24
|
+
#
|
25
|
+
def start(blocking = true)
|
26
|
+
@mutex.synchronize do
|
27
|
+
return if @stop == false
|
28
|
+
super
|
29
|
+
end
|
30
|
+
|
31
|
+
@poll_thread = Thread.new { poll }
|
32
|
+
@poll_thread.join if blocking
|
26
33
|
end
|
27
34
|
|
28
35
|
# Stop the adapter.
|
29
36
|
#
|
30
37
|
def stop
|
31
|
-
|
38
|
+
@mutex.synchronize do
|
39
|
+
return if @stop == true
|
40
|
+
super
|
41
|
+
end
|
42
|
+
|
43
|
+
@poll_thread.join
|
32
44
|
end
|
33
45
|
|
34
46
|
private
|
@@ -37,10 +49,11 @@ module Listen
|
|
37
49
|
#
|
38
50
|
def poll
|
39
51
|
until @stop
|
40
|
-
sleep
|
52
|
+
sleep(0.1) && next if @paused
|
41
53
|
|
42
54
|
start = Time.now.to_f
|
43
|
-
@callback.call(
|
55
|
+
@callback.call(@directories.dup, :recursive => true)
|
56
|
+
@turnstile.signal
|
44
57
|
nap_time = @latency - (Time.now.to_f - start)
|
45
58
|
sleep(nap_time) if nap_time > 0
|
46
59
|
end
|
@@ -7,30 +7,42 @@ module Listen
|
|
7
7
|
#
|
8
8
|
class Windows < Adapter
|
9
9
|
|
10
|
-
#
|
10
|
+
# Initializes the Adapter. See {Listen::Adapter#initialize} for more info.
|
11
11
|
#
|
12
|
-
def initialize(
|
12
|
+
def initialize(directories, options = {}, &callback)
|
13
13
|
super
|
14
|
-
@
|
15
|
-
init_worker
|
14
|
+
@worker = init_worker
|
16
15
|
end
|
17
16
|
|
18
|
-
#
|
17
|
+
# Starts the adapter.
|
19
18
|
#
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
19
|
+
# @param [Boolean] blocking whether or not to block the current thread after starting
|
20
|
+
#
|
21
|
+
def start(blocking = true)
|
22
|
+
@mutex.synchronize do
|
23
|
+
return if @stop == false
|
24
|
+
super
|
25
|
+
end
|
26
|
+
|
27
|
+
@worker_thread = Thread.new { @worker.run }
|
28
|
+
@poll_thread = Thread.new { poll_changed_dirs(true) }
|
29
|
+
@poll_thread.join if blocking
|
24
30
|
end
|
25
31
|
|
26
|
-
#
|
32
|
+
# Stops the adapter.
|
27
33
|
#
|
28
34
|
def stop
|
29
|
-
|
35
|
+
@mutex.synchronize do
|
36
|
+
return if @stop == true
|
37
|
+
super
|
38
|
+
end
|
39
|
+
|
30
40
|
@worker.stop
|
41
|
+
Thread.kill(@worker_thread) if @worker_thread
|
42
|
+
@poll_thread.join
|
31
43
|
end
|
32
44
|
|
33
|
-
#
|
45
|
+
# Checks if the adapter is usable on the current OS.
|
34
46
|
#
|
35
47
|
# @return [Boolean] whether usable or not
|
36
48
|
#
|
@@ -45,28 +57,23 @@ module Listen
|
|
45
57
|
|
46
58
|
private
|
47
59
|
|
48
|
-
#
|
60
|
+
# Initializes a FChange worker and adds a watcher for
|
61
|
+
# each directory passed to the adapter.
|
49
62
|
#
|
50
|
-
|
51
|
-
@worker = FChange::Notifier.new
|
52
|
-
@worker.watch(@directory, :all_events, :recursive) do |event|
|
53
|
-
next if @paused
|
54
|
-
|
55
|
-
@changed_dirs << File.expand_path(event.watcher.path)
|
56
|
-
end
|
57
|
-
end
|
58
|
-
|
59
|
-
# Polling around @changed_dirs presence.
|
63
|
+
# @return [FChange::Notifier] initialized worker
|
60
64
|
#
|
61
|
-
def
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
65
|
+
def init_worker
|
66
|
+
worker = FChange::Notifier.new
|
67
|
+
@directories.each do |directory|
|
68
|
+
watcher = worker.watch(directory, :all_events, :recursive) do |event|
|
69
|
+
next if @paused
|
70
|
+
@mutex.synchronize do
|
71
|
+
@changed_dirs << File.expand_path(event.watcher.path)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
worker.add_watcher(watcher)
|
69
75
|
end
|
76
|
+
worker
|
70
77
|
end
|
71
78
|
|
72
79
|
end
|
@@ -0,0 +1,254 @@
|
|
1
|
+
require 'set'
|
2
|
+
require 'find'
|
3
|
+
require 'pathname'
|
4
|
+
require 'digest/sha1'
|
5
|
+
|
6
|
+
module Listen
|
7
|
+
|
8
|
+
# The directory record stores information about
|
9
|
+
# a directory and keeps track of changes to
|
10
|
+
# the structure of its childs.
|
11
|
+
#
|
12
|
+
class DirectoryRecord
|
13
|
+
attr_reader :directory, :paths, :sha1_checksums
|
14
|
+
|
15
|
+
# Default paths' beginnings that doesn't get stored in the record
|
16
|
+
DEFAULT_IGNORED_PATHS = %w[.bundle .git .DS_Store log tmp vendor]
|
17
|
+
|
18
|
+
# Initializes a directory record.
|
19
|
+
#
|
20
|
+
# @option [String] directory the directory to keep track of
|
21
|
+
#
|
22
|
+
def initialize(directory)
|
23
|
+
@directory = directory
|
24
|
+
raise ArgumentError, "The path '#{directory}' is not a directory!" unless File.directory?(@directory)
|
25
|
+
|
26
|
+
@ignored_paths = Set.new(DEFAULT_IGNORED_PATHS)
|
27
|
+
@filters = Set.new
|
28
|
+
@sha1_checksums = Hash.new
|
29
|
+
end
|
30
|
+
|
31
|
+
# Returns the ignored paths in the record
|
32
|
+
#
|
33
|
+
# @return [Array<String>] the ignored paths
|
34
|
+
#
|
35
|
+
def ignored_paths
|
36
|
+
@ignored_paths.to_a
|
37
|
+
end
|
38
|
+
|
39
|
+
# Returns the filters used in the record to know
|
40
|
+
# which paths should be stored.
|
41
|
+
#
|
42
|
+
# @return [Array<String>] the used filters
|
43
|
+
#
|
44
|
+
def filters
|
45
|
+
@filters.to_a
|
46
|
+
end
|
47
|
+
|
48
|
+
# Adds ignored path to the record.
|
49
|
+
#
|
50
|
+
# @example Ignore some paths
|
51
|
+
# ignore ".git", ".svn"
|
52
|
+
#
|
53
|
+
# @param [String, Array<String>] paths a path or a list of paths to ignore
|
54
|
+
#
|
55
|
+
def ignore(*paths)
|
56
|
+
@ignored_paths.merge(paths)
|
57
|
+
end
|
58
|
+
|
59
|
+
# Adds file filters to the listener.
|
60
|
+
#
|
61
|
+
# @example Filter some files
|
62
|
+
# ignore /\.txt$/, /.*\.zip/
|
63
|
+
#
|
64
|
+
# @param [Array<Regexp>] regexps a list of regexps file filters
|
65
|
+
#
|
66
|
+
# @return [Listen::Listener] the listener itself
|
67
|
+
#
|
68
|
+
def filter(*regexps)
|
69
|
+
@filters.merge(regexps)
|
70
|
+
end
|
71
|
+
|
72
|
+
# Returns whether a path should be ignored or not.
|
73
|
+
#
|
74
|
+
# @param [String] path the path to test.
|
75
|
+
#
|
76
|
+
# @return [Boolean]
|
77
|
+
#
|
78
|
+
def ignored?(path)
|
79
|
+
@ignored_paths.any? { |ignored_path| path =~ /#{ignored_path}$/ }
|
80
|
+
end
|
81
|
+
|
82
|
+
# Returns whether a path should be filtered or not.
|
83
|
+
#
|
84
|
+
# @param [String] path the path to test.
|
85
|
+
#
|
86
|
+
# @return [Boolean]
|
87
|
+
#
|
88
|
+
def filtered?(path)
|
89
|
+
@filters.empty? || @filters.any? { |filter| path =~ filter }
|
90
|
+
end
|
91
|
+
|
92
|
+
# Finds the paths that should be stored and adds them
|
93
|
+
# to the paths' hash.
|
94
|
+
#
|
95
|
+
def build
|
96
|
+
@paths = Hash.new { |h, k| h[k] = Hash.new }
|
97
|
+
important_paths { |path| insert_path(path) }
|
98
|
+
@updated_at = Time.now.to_i
|
99
|
+
end
|
100
|
+
|
101
|
+
# Detects changes in the passed directories, updates
|
102
|
+
# the record with the new changes and returns the changes
|
103
|
+
#
|
104
|
+
# @param [Array] directories the list of directories scan for changes
|
105
|
+
# @param [Hash] options
|
106
|
+
# @option options [Boolean] recursive scan all sub-directories recursively
|
107
|
+
# @option options [Boolean] relative_paths whether or not to use relative paths for changes
|
108
|
+
#
|
109
|
+
# @return [Hash<Array>] the changes
|
110
|
+
#
|
111
|
+
def fetch_changes(directories, options = {})
|
112
|
+
@changes = { :modified => [], :added => [], :removed => [] }
|
113
|
+
directories = directories.sort_by { |el| el.length }.reverse # diff sub-dir first
|
114
|
+
directories.each do |directory|
|
115
|
+
next unless directory[@directory] # Path is or inside directory
|
116
|
+
detect_modifications_and_removals(directory, options)
|
117
|
+
detect_additions(directory, options)
|
118
|
+
end
|
119
|
+
@updated_at = Time.now.to_i
|
120
|
+
@changes
|
121
|
+
end
|
122
|
+
|
123
|
+
private
|
124
|
+
|
125
|
+
# Detects modifications and removals recursively in a directory.
|
126
|
+
#
|
127
|
+
# @note Modifications detection begins by checking the modification time (mtime)
|
128
|
+
# of files and then by checking content changes (using SHA1-checksum)
|
129
|
+
# when the mtime of files is not changed.
|
130
|
+
#
|
131
|
+
# @param [String] directory the path to analyze
|
132
|
+
# @param [Hash] options
|
133
|
+
# @option options [Boolean] recursive scan all sub-directories recursively
|
134
|
+
# @option options [Boolean] relative_paths whether or not to use relative paths for changes
|
135
|
+
#
|
136
|
+
def detect_modifications_and_removals(directory, options = {})
|
137
|
+
@paths[directory].each do |basename, type|
|
138
|
+
path = File.join(directory, basename)
|
139
|
+
|
140
|
+
case type
|
141
|
+
when 'Dir'
|
142
|
+
if File.directory?(path)
|
143
|
+
detect_modifications_and_removals(path, options) if options[:recursive]
|
144
|
+
else
|
145
|
+
detect_modifications_and_removals(path, { :recursive => true }.merge(options))
|
146
|
+
@paths[directory].delete(basename)
|
147
|
+
@paths.delete("#{directory}/#{basename}")
|
148
|
+
end
|
149
|
+
when 'File'
|
150
|
+
if File.exist?(path)
|
151
|
+
new_mtime = File.mtime(path).to_i
|
152
|
+
if @updated_at < new_mtime || (@updated_at == new_mtime && content_modified?(path))
|
153
|
+
@changes[:modified] << (options[:relative_paths] ? relative_to_base(path) : path)
|
154
|
+
end
|
155
|
+
else
|
156
|
+
@paths[directory].delete(basename)
|
157
|
+
@sha1_checksums.delete(path)
|
158
|
+
@changes[:removed] << (options[:relative_paths] ? relative_to_base(path) : path)
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
# Detects additions in a directory.
|
165
|
+
#
|
166
|
+
# @param [String] directory the path to analyze
|
167
|
+
# @param [Hash] options
|
168
|
+
# @option options [Boolean] recursive scan all sub-directories recursively
|
169
|
+
# @option options [Boolean] relative_paths whether or not to use relative paths for changes
|
170
|
+
#
|
171
|
+
def detect_additions(directory, options = {})
|
172
|
+
Find.find(directory) do |path|
|
173
|
+
next if path == @directory
|
174
|
+
|
175
|
+
if File.directory?(path)
|
176
|
+
if ignored?(path) || (directory != path && (!options[:recursive] && existing_path?(path)))
|
177
|
+
Find.prune # Don't look any further into this directory.
|
178
|
+
else
|
179
|
+
insert_path(path)
|
180
|
+
end
|
181
|
+
elsif !ignored?(path) && filtered?(path) && !existing_path?(path)
|
182
|
+
if File.file?(path)
|
183
|
+
@changes[:added] << (options[:relative_paths] ? relative_to_base(path) : path)
|
184
|
+
end
|
185
|
+
insert_path(path)
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
# Returns whether or not a file's content has been modified by
|
191
|
+
# comparing the SHA1-checksum to a stored one.
|
192
|
+
#
|
193
|
+
# @param [String] path the file path
|
194
|
+
#
|
195
|
+
def content_modified?(path)
|
196
|
+
sha1_checksum = Digest::SHA1.file(path).to_s
|
197
|
+
if @sha1_checksums[path] != sha1_checksum
|
198
|
+
@sha1_checksums[path] = sha1_checksum
|
199
|
+
true
|
200
|
+
else
|
201
|
+
false
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
# Traverses the base directory looking for paths that should
|
206
|
+
# be stored; thus paths that are filters or not ignored.
|
207
|
+
#
|
208
|
+
# @yield [path] an important path
|
209
|
+
#
|
210
|
+
def important_paths
|
211
|
+
Find.find(@directory) do |path|
|
212
|
+
next if path == @directory
|
213
|
+
|
214
|
+
if File.directory?(path)
|
215
|
+
if ignored?(path)
|
216
|
+
Find.prune # Don't look any further into this directory.
|
217
|
+
else
|
218
|
+
yield(path)
|
219
|
+
end
|
220
|
+
elsif !ignored?(path) && filtered?(path)
|
221
|
+
yield(path)
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
# Inserts a path with its type (Dir or File) in paths hash.
|
227
|
+
#
|
228
|
+
# @param [String] path the path to insert in @paths.
|
229
|
+
#
|
230
|
+
def insert_path(path)
|
231
|
+
@paths[File.dirname(path)][File.basename(path)] = File.directory?(path) ? 'Dir' : 'File'
|
232
|
+
end
|
233
|
+
|
234
|
+
# Returns whether or not a path exists in the paths hash.
|
235
|
+
#
|
236
|
+
# @param [String] path the path to check
|
237
|
+
#
|
238
|
+
# @return [Boolean]
|
239
|
+
#
|
240
|
+
def existing_path?(path)
|
241
|
+
@paths[File.dirname(path)][File.basename(path)] != nil
|
242
|
+
end
|
243
|
+
|
244
|
+
# Converts an absolute path to a path that's relative to the base directory.
|
245
|
+
#
|
246
|
+
# @param [String] path the path to convert
|
247
|
+
#
|
248
|
+
# @return [String] the relative path
|
249
|
+
#
|
250
|
+
def relative_to_base(path)
|
251
|
+
path.sub(%r(^#{@directory}/?/), '')
|
252
|
+
end
|
253
|
+
end
|
254
|
+
end
|