right_scraper 1.0.4 → 1.0.5

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.
@@ -40,8 +40,12 @@ module RightScale
40
40
  #
41
41
  # === Parameters
42
42
  # scrape_dir(String):: Scrape destination directory
43
- def initialize(scrape_dir)
43
+ # max_bytes(Integer):: Maximum size allowed for repos, -1 for no limit (default)
44
+ # max_seconds(Integer):: Maximum number of seconds a single scrape operation should take, -1 for no limit (default)
45
+ def initialize(scrape_dir, max_bytes = -1, max_seconds = -1)
44
46
  @scrape_dir = scrape_dir
47
+ @max_bytes = max_bytes
48
+ @max_seconds = max_seconds
45
49
  @scrapers = {}
46
50
  end
47
51
 
@@ -70,7 +74,7 @@ module RightScale
70
74
  def scrape(repo, &callback)
71
75
  repo = RightScale::Repository.from_hash(repo) if repo.is_a?(Hash)
72
76
  raise "Invalid repository type" unless SCRAPERS.include?(repo.repo_type)
73
- @scraper = @scrapers[repo.repo_type] ||= SCRAPERS[repo.repo_type].new(@scrape_dir)
77
+ @scraper = @scrapers[repo.repo_type] ||= SCRAPERS[repo.repo_type].new(@scrape_dir, @max_bytes, @max_seconds)
74
78
  @scraper.scrape(repo, &callback)
75
79
  @last_repo_dir = @scraper.current_repo_dir
76
80
  @scraper.succeeded?
@@ -41,12 +41,15 @@ module RightScale
41
41
  # (String) Path to local directory where repository was downloaded
42
42
  attr_reader :current_repo_dir
43
43
 
44
- # Set path to directory containing all scraped repos
44
+ # Set path to directory containing all scraped repos as well as space and time upperbounds
45
45
  #
46
46
  # === Parameters
47
47
  # root_dir(String):: Path to scraped repos parent directory
48
- def initialize(root_dir)
48
+ # max_bytes(Integer):: Maximum size allowed for repos, -1 for no limit (default)
49
+ # max_seconds(Integer):: Maximum number of seconds a single scrape operation should take, -1 for no limit (default)
50
+ def initialize(root_dir, max_bytes, max_seconds)
49
51
  @root_dir = root_dir
52
+ @watcher = Watcher.new(max_bytes, max_seconds)
50
53
  end
51
54
 
52
55
  # Common implementation of scrape method for all repository types.
@@ -108,6 +111,35 @@ module RightScale
108
111
  def scrape_imp
109
112
  raise "Method not implemented"
110
113
  end
111
-
114
+
115
+ # Update state of scraper according to status returned by watcher
116
+ #
117
+ # === Parameters
118
+ # res(RightScale::WatchResult):: Watcher status to be analyzed
119
+ # msg_title(String):: Error message title in case of failure
120
+ # update(TrueClass|FalseClass):: Whether the process was launch to incrementally update the repo
121
+ # ok_codes:: Successful process return codes, only 0 by default
122
+ #
123
+ # === Return
124
+ # true:: Always return true
125
+ def handle_watcher_result(res, msg_title, update, ok_codes=[0])
126
+ if res.status == :timeout
127
+ @errors << "#{msg_title} is taking more time than #{@watcher.max_seconds / 60} minutes, aborting..."
128
+ FileUtils.rm_rf(@current_repo_dir)
129
+ elsif res.status == :size_exceeded
130
+ @errors << "#{msg_title} is taking more space than #{@watcher.max_bytes / 1048576} MB, aborting..."
131
+ FileUtils.rm_rf(@current_repo_dir)
132
+ elsif !ok_codes.include?(res.exit_code)
133
+ if update
134
+ @callback.call("#{msg_title} failed: #{res.output}, reverting to non incremental update", is_step=false) if @callback
135
+ FileUtils.rm_rf(@current_repo_dir)
136
+ @incremental = false
137
+ else
138
+ @errors << "#{msg_title} failed: #{res.output}"
139
+ end
140
+ end
141
+ true
142
+ end
143
+
112
144
  end
113
145
  end
@@ -36,8 +36,8 @@ module RightScale
36
36
  user_opt = @repo.first_credential && @repo.second_credential ? "--user #{@repo.first_credential}:#{@repo.second_credential}" : ''
37
37
  cmd = "curl --fail --silent --show-error --insecure --location #{user_opt} --output \"#{@current_repo_dir}/#{filename}\" '#{@repo.url}' 2>&1"
38
38
  FileUtils.mkdir_p(@current_repo_dir)
39
- res = `#{cmd}`
40
- @errors << res if $? != 0
39
+ res = @watcher.launch_and_watch(cmd, @current_repo_dir)
40
+ handle_watcher_result(res, 'Download', update=false)
41
41
  if succeeded?
42
42
  unzip_opt = case @repo.url[/\.(.*)$/]
43
43
  when 'bzip', 'bzip2', 'bz2' then 'j'
@@ -48,19 +48,17 @@ module RightScale
48
48
  msg += "git repository '#{@repo.display_name}'"
49
49
  @callback.call(msg, is_step=true) if @callback
50
50
  ssh_cmd = ssh_command
51
- res = ''
52
51
  is_tag = nil
53
52
  is_branch = nil
54
- update_failed = false
55
53
 
56
54
  if @incremental
57
55
  Dir.chdir(@current_repo_dir) do
58
- analysis = git_fetch_and_analyze(ssh_cmd)
59
- if analysis[:success]
56
+ analysis = git_fetch_and_analyze(ssh_cmd, update=true)
57
+ if succeeded? && @incremental
60
58
  is_tag = analysis[:tag]
61
59
  is_branch = analysis[:branch]
62
60
  if !is_tag && !is_branch
63
- @callback.call('Nothing to update: repo tag refers to neither a branch nor a tag', is_step=false)
61
+ @callback.call('Nothing to update: repo tag refers to neither a branch nor a tag', is_step=false) if @callback
64
62
  return true
65
63
  end
66
64
  if is_tag && is_branch
@@ -70,35 +68,25 @@ module RightScale
70
68
  res = `git checkout #{tag} 2>&1`
71
69
  if $? != 0
72
70
  @callback.call("Failed to checkout #{tag}: #{res}, falling back to cloning", is_step=false) if @callback
73
- update_failed = true
71
+ FileUtils.rm_rf(@current_repo_dir)
72
+ @incremental = false
74
73
  end
75
74
  end
76
- else
77
- @callback.call("Failed to update repo: #{analysis[:output]}, falling back to cloning", is_step=false) if @callback
78
- update_failed = true
79
75
  end
80
76
  end
81
77
  end
82
- if update_failed
83
- FileUtils.rm_rf(@current_repo_dir)
84
- @incremental = false
85
- end
86
78
  if !@incremental && succeeded?
87
- res += `#{ssh_cmd} git clone --quiet --depth 1 "#{@repo.url}" "#{@current_repo_dir}" 2>&1`
88
- @errors << "Failed to clone repo: #{res}" if $? != 0
79
+ git_cmd = "#{ssh_cmd} git clone --quiet --depth 1 \"#{@repo.url}\" \"#{@current_repo_dir}\" 2>&1"
80
+ res = @watcher.launch_and_watch(git_cmd, @current_repo_dir)
81
+ handle_watcher_result(res, 'git clone', update=false)
89
82
  if !@repo.tag.nil? && !@repo.tag.empty? && @repo.tag != 'master' && succeeded?
90
83
  Dir.chdir(@current_repo_dir) do
91
84
  if is_tag.nil?
92
- analysis = git_fetch_and_analyze(ssh_cmd)
93
- if analysis[:success]
94
- is_tag = analysis[:tag]
95
- is_branch = analysis[:branch]
96
- res += analysis[:output]
97
- else
98
- @errors << "Failed to analyze repo: #{res}"
99
- end
85
+ analysis = git_fetch_and_analyze(ssh_cmd, update=false)
86
+ is_tag = analysis[:tag]
87
+ is_branch = analysis[:branch]
100
88
  end
101
- if succeded?
89
+ if succeeded?
102
90
  if is_tag && is_branch
103
91
  @errors << 'Repository tag ambiguous: could be git tag or git branch'
104
92
  elsif is_branch
@@ -202,26 +190,27 @@ module RightScale
202
190
  # Shallow fetch
203
191
  # Resolves whehter repository tag is a git tag or a git branch
204
192
  # Return output of run commands too
193
+ # Update errors collection upon failure (check for succeeded? after call)
205
194
  # Note: Assume that current working directory is a git directory
206
195
  #
207
196
  # === Parameters
208
197
  # ssh_cmd(String):: SSH command to be used with git if any
198
+ # update(FalseClass|TrueClass):: Whether analysis is done as part of an update
209
199
  #
210
200
  # === Return
211
201
  # res(Hash)::
212
- # - resp[:success]:: true if fetch was successful, false otherwise
213
- # - res[:output] contains the git output
214
202
  # - res[:tag]:: is true if git repo has a tag with a name corresponding to the repository tag
215
203
  # - res[:branch] is true if git repo has a branch with a name corresponding to the repository tag
216
- def git_fetch_and_analyze(ssh_cmd)
217
- output = `#{ssh_cmd} git fetch --tags --depth 1 2>&1`
218
- success = [0, 1].include? $?.exitstatus # git fetch returns 1 when there is nothing to fetch
204
+ def git_fetch_and_analyze(ssh_cmd, update)
205
+ git_cmd = "#{ssh_cmd} git fetch --tags --depth 1 2>&1"
206
+ res = @watcher.launch_and_watch(git_cmd, @current_repo_dir)
207
+ handle_watcher_result(res, 'git fetch', update, ok_codes=[0, 1]) # git fetch returns 1 when there is nothing to fetch
219
208
  is_tag = is_branch = false
220
- if success
209
+ if succeeded? && (!update || @incremental)
221
210
  is_tag = `git tag`.split("\n").include?(@repo.tag)
222
211
  is_branch = `git branch -r`.split("\n").map { |t| t.strip }.include?("origin/#{@repo.tag}")
223
212
  end
224
- { :success => success, :output => output, :tag => is_tag, :branch => is_branch }
213
+ { :tag => is_tag, :branch => is_branch }
225
214
  end
226
215
 
227
216
  end
@@ -53,22 +53,18 @@ module RightScale
53
53
  (@repo.second_credential ? " --password #{@repo.second_credential}" : '') +
54
54
  ' 2>&1'
55
55
  Dir.chdir(@current_repo_dir) do
56
- res = `#{svn_cmd}`
57
- if $? != 0
58
- @callback.call("Failed to update repo: #{res}, falling back to checkout", is_step=false) if @callback
59
- FileUtils.rm_rf(@current_repo_dir)
60
- @incremental = false
61
- end
56
+ res = @watcher.launch_and_watch(svn_cmd, @current_repo_dir)
57
+ handle_watcher_result(res, 'SVN update', update=true)
62
58
  end
63
59
  end
64
- if !@incremental
60
+ if !@incremental && succeeded?
65
61
  svn_cmd = "svn checkout \"#{@repo.url}\" \"#{@current_repo_dir}\" --no-auth-cache --non-interactive --quiet" +
66
62
  (!@repo.tag.nil? && !@repo.tag.empty? ? " --revision #{@repo.tag}" : '') +
67
63
  (@repo.first_credential ? " --username #{@repo.first_credential}" : '') +
68
64
  (@repo.second_credential ? " --password #{@repo.second_credential}" : '') +
69
65
  ' 2>&1'
70
- res = `#{svn_cmd}`
71
- @errors << res if $? != 0
66
+ res = @watcher.launch_and_watch(svn_cmd, @current_repo_dir)
67
+ handle_watcher_result(res, 'SVN checkout', update=false)
72
68
  end
73
69
  true
74
70
  end
@@ -0,0 +1,141 @@
1
+ #--
2
+ # Copyright: Copyright (c) 2010 RightScale, Inc.
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining
5
+ # a copy of this software and associated documentation files (the
6
+ # 'Software'), to deal in the Software without restriction, including
7
+ # without limitation the rights to use, copy, modify, merge, publish,
8
+ # distribute, sublicense, and/or sell copies of the Software, and to
9
+ # permit persons to whom the Software is furnished to do so, subject to
10
+ # the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be
13
+ # included in all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
16
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
18
+ # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
19
+ # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
20
+ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
21
+ # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22
+ #++
23
+
24
+ require 'find'
25
+
26
+ module RightScale
27
+
28
+ # Encapsulate information returned by watcher
29
+ class WatchStatus
30
+
31
+ # Potential outcome of watcher
32
+ VALID_STATUSES = [ :success, :timeout, :size_exceeded ]
33
+
34
+ attr_reader :status # One of VALID_STATUSES
35
+ attr_reader :exit_code # Watched process exit code or -1 if process was killed
36
+ attr_reader :output # Watched process combined output
37
+
38
+ # Initialize attibutes
39
+ def initialize(status, exit_code, output)
40
+ @status = status
41
+ @exit_code = exit_code
42
+ @output = output
43
+ end
44
+
45
+ end
46
+
47
+ class Watcher
48
+
49
+ attr_reader :max_bytes # Maximum size in bytes of watched directory before process is killed
50
+ attr_reader :max_seconds # Maximum number of elapased seconds before external process is killed
51
+
52
+ # Initialize attributes
53
+ #
54
+ # max_bytes(Integer):: Maximum size in bytes of watched directory before process is killed
55
+ # max_seconds(Integer):: Maximum number of elapased seconds before external process is killed
56
+ def initialize(max_bytes, max_seconds)
57
+ @max_bytes = max_bytes
58
+ @max_seconds = max_seconds
59
+ end
60
+
61
+ # Launch given command as external process and watch given directory
62
+ # so it doesn't exceed given size. Also watch time elapsed and kill
63
+ # external process if either the size of the watched directory exceed
64
+ # @max_bytes or the time elapsed exceeds @max_seconds.
65
+ # Note: This method is not thread-safe, instantiate one watcher per thread
66
+ #
67
+ # === Parameters
68
+ # cmd(String):: Command line to be launched
69
+ # dest_dir(String):: Watched directory
70
+ #
71
+ # === Return
72
+ # res(RightScale::WatchStatus):: Outcome of watch, see RightScale::WatchStatus
73
+ def launch_and_watch(cmd, dest_dir)
74
+ status = nil
75
+ output = ''
76
+
77
+ # Run external process and monitor it in a new thread
78
+ r = IO.popen(cmd)
79
+ Thread.new do
80
+ Process.wait(r.pid)
81
+ status = $?
82
+ end
83
+
84
+ # Loop until process is done or times out or takes too much space
85
+ timed_out = repeat(1, @max_seconds) do
86
+ output += r.readlines.join
87
+ size = 0
88
+ Find.find(dest_dir) { |f| size += File.stat(f).size unless File.directory?(f) } if File.directory?(dest_dir)
89
+ size > @max_bytes || status
90
+ end
91
+
92
+ # Cleanup and report status
93
+ output += r.readlines.join
94
+ Process.kill('TERM', r.pid) unless status
95
+ r.close
96
+ s = status ? :success : (timed_out ? :timeout : :size_exceeded)
97
+ exit_code = status && status.exitstatus || -1
98
+ res = WatchStatus.new(s, exit_code, output)
99
+ end
100
+
101
+ protected
102
+
103
+ # Run given block in thread and time execution
104
+ #
105
+ # === Block
106
+ # Block whose execution is timed
107
+ #
108
+ # === Return
109
+ # elapsed(Integer):: Number of seconds elapsed while running given block
110
+ def timed
111
+ start_at = Time.now
112
+ yield
113
+ elapsed = Time.now - start_at
114
+ end
115
+
116
+ # Repeat given block at regular intervals
117
+ #
118
+ # === Parameters
119
+ # seconds(Integer):: Number of seconds between executions
120
+ # timeout(Integer):: Timeout after which execution stops and method returns
121
+ #
122
+ # === Block
123
+ # Given block gets executed every period seconds until timeout is reached
124
+ # *or* block returns true
125
+ #
126
+ # === Return
127
+ # res(TrueClass|FalseClass):: true if timeout is reached, false otherwise.
128
+ def repeat(period, timeout)
129
+ end_at = Time.now + timeout
130
+ while res = (Time.now < end_at)
131
+ exit = false
132
+ elapsed = timed { exit = yield }
133
+ break if exit
134
+ sleep(period - elapsed) if elapsed < period
135
+ end
136
+ !res
137
+ end
138
+
139
+ end
140
+
141
+ end
@@ -23,7 +23,7 @@ require 'rubygems'
23
23
 
24
24
  spec = Gem::Specification.new do |spec|
25
25
  spec.name = 'right_scraper'
26
- spec.version = '1.0.4'
26
+ spec.version = '1.0.5'
27
27
  spec.authors = ['Raphael Simon']
28
28
  spec.email = 'raphael@rightscale.com'
29
29
  spec.homepage = 'https://github.com/rightscale/right_scraper'
@@ -0,0 +1,5 @@
1
+ [core]
2
+ repositoryformatversion = 0
3
+ filemode = true
4
+ bare = true
5
+ ignorecase = true
@@ -65,7 +65,7 @@ describe RightScale::DownloadScraper do
65
65
  end
66
66
 
67
67
  before(:each) do
68
- @scraper = RightScale::DownloadScraper.new(@repo_path)
68
+ @scraper = RightScale::DownloadScraper.new(@repo_path, max_bytes=1024**2, max_seconds=20)
69
69
  @repo = RightScale::Repository.from_hash(:display_name => 'test repo',
70
70
  :repo_type => :download,
71
71
  :url => "file:///#{@download_file}")
@@ -24,6 +24,7 @@
24
24
  require File.join(File.dirname(__FILE__), 'spec_helper')
25
25
  require 'scraper_base'
26
26
  require 'repository'
27
+ require 'watcher'
27
28
  require File.join('scrapers', 'git_scraper')
28
29
 
29
30
  describe RightScale::GitScraper do
@@ -70,7 +71,7 @@ describe RightScale::GitScraper do
70
71
  end
71
72
 
72
73
  before(:each) do
73
- @scraper = RightScale::GitScraper.new(@repo_path)
74
+ @scraper = RightScale::GitScraper.new(@repo_path, max_bytes=1024**2, max_seconds=20)
74
75
  @repo = RightScale::Repository.from_hash(:display_name => 'test repo',
75
76
  :repo_type => :git,
76
77
  :url => @origin_path)
@@ -27,7 +27,7 @@ require 'scraper_base'
27
27
  describe RightScale::ScraperBase do
28
28
 
29
29
  before(:each) do
30
- @base = RightScale::ScraperBase.new('/tmp')
30
+ @base = RightScale::ScraperBase.new('/tmp', max_bytes=1024**2, max_seconds=20)
31
31
  end
32
32
 
33
33
  it 'should initialize the scrape directory' do
@@ -66,7 +66,7 @@ describe RightScale::SvnScraper do
66
66
  end
67
67
 
68
68
  before(:each) do
69
- @scraper = RightScale::SvnScraper.new(@repo_path)
69
+ @scraper = RightScale::SvnScraper.new(@repo_path, max_bytes=1024**2, max_seconds=20)
70
70
  @repo = RightScale::Repository.from_hash(:display_name => 'test repo',
71
71
  :repo_type => :svn,
72
72
  :url => "file://#{@svn_repo_path}")
@@ -0,0 +1,62 @@
1
+ #--
2
+ # Copyright: Copyright (c) 2010 RightScale, Inc.
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining
5
+ # a copy of this software and associated documentation files (the
6
+ # 'Software'), to deal in the Software without restriction, including
7
+ # without limitation the rights to use, copy, modify, merge, publish,
8
+ # distribute, sublicense, and/or sell copies of the Software, and to
9
+ # permit persons to whom the Software is furnished to do so, subject to
10
+ # the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be
13
+ # included in all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
16
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
18
+ # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
19
+ # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
20
+ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
21
+ # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22
+ #++
23
+
24
+ require File.join(File.dirname(__FILE__), 'spec_helper')
25
+ require 'watcher'
26
+
27
+ describe RightScale::Watcher do
28
+
29
+ before(:each) do
30
+ @dest_dir = File.join(File.dirname(__FILE__), '__destdir')
31
+ FileUtils.mkdir_p(@dest_dir)
32
+ end
33
+
34
+ after(:each) do
35
+ FileUtils.rm_rf(@dest_dir)
36
+ end
37
+
38
+ it 'should launch and watch well-behaved processes' do
39
+ watcher = RightScale::Watcher.new(max_bytes=1, max_seconds=5)
40
+ status = watcher.launch_and_watch('ruby -e "puts 42; exit 42"', @dest_dir)
41
+ status.status.should == :success
42
+ status.exit_code.should == 42
43
+ status.output.should == "42\n"
44
+ end
45
+
46
+ it 'should report timeouts' do
47
+ watcher = RightScale::Watcher.new(max_bytes=1, max_seconds=2)
48
+ status = watcher.launch_and_watch('ruby -e "puts 42; sleep 3"', @dest_dir)
49
+ status.status.should == :timeout
50
+ status.exit_code.should == -1
51
+ status.output.should == "42\n"
52
+ end
53
+
54
+ it 'should report size exceeded' do
55
+ watcher = RightScale::Watcher.new(max_bytes=1, max_seconds=5)
56
+ status = watcher.launch_and_watch("ruby -e 'puts 42; File.open(File.join(\"#{@dest_dir}\", \"test\"), \"w\") { |f| f.puts \"MORE THAN 2 CHARS\" }'", @dest_dir)
57
+ status.status.should == :size_exceeded
58
+ status.exit_code.should == -1
59
+ status.output.should == "42\n"
60
+ end
61
+
62
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: right_scraper
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.4
4
+ version: 1.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raphael Simon
@@ -9,7 +9,7 @@ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
 
12
- date: 2010-02-05 00:00:00 -08:00
12
+ date: 2010-02-08 00:00:00 -08:00
13
13
  default_executable:
14
14
  dependencies: []
15
15
 
@@ -32,7 +32,9 @@ files:
32
32
  - lib/right_scraper/scrapers/download_scraper.rb
33
33
  - lib/right_scraper/scrapers/git_scraper.rb
34
34
  - lib/right_scraper/scrapers/svn_scraper.rb
35
+ - lib/right_scraper/watcher.rb
35
36
  - right_scraper.gemspec
37
+ - spec/__origin/config
36
38
  - spec/download_scraper_spec.rb
37
39
  - spec/git_scraper_spec.rb
38
40
  - spec/rcov.opts
@@ -42,6 +44,7 @@ files:
42
44
  - spec/spec.opts
43
45
  - spec/spec_helper.rb
44
46
  - spec/svn_scraper_spec.rb
47
+ - spec/watcher_spec.rb
45
48
  has_rdoc: true
46
49
  homepage: https://github.com/rightscale/right_scraper
47
50
  licenses: []