right_scraper 1.0.4 → 1.0.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []