scint 0.1.0 → 0.7.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.
@@ -13,9 +13,10 @@ module Scint
13
13
  # Lookup order (first match wins):
14
14
  # 1. Inline credentials in the request URI itself (user:pass@host)
15
15
  # 2. Credentials registered at runtime (from Gemfile source: URIs)
16
- # 3. Scint config: $XDG_CONFIG_HOME/scint/credentials
17
- # 4. Bundler config: ~/.bundle/config
18
- # 5. Environment variables (BUNDLE_HOST__NAME format)
16
+ # 3. Bundler local config: $BUNDLE_APP_CONFIG/config or ./.bundle/config
17
+ # 4. Scint config: $XDG_CONFIG_HOME/scint/credentials
18
+ # 5. Bundler global config: ~/.bundle/config
19
+ # 6. Environment variables (BUNDLE_HOST__NAME / BUNDLE_HTTPS://... format)
19
20
  #
20
21
  # All config files use Bundler's key format:
21
22
  # BUNDLE_PKGS__SHOPIFY__IO: "token:secret"
@@ -80,7 +81,7 @@ module Scint
80
81
  end
81
82
 
82
83
  # 2–5. Registered + config files + env
83
- auth = lookup_host(uri.host)
84
+ auth = lookup_uri(uri)
84
85
  return nil unless auth
85
86
 
86
87
  user, password = auth.split(":", 2)
@@ -99,27 +100,35 @@ module Scint
99
100
 
100
101
  private
101
102
 
102
- def lookup_host(host)
103
- return nil unless host
103
+ def lookup_uri(uri)
104
+ return nil unless uri&.host
104
105
 
105
106
  # 2. Runtime-registered (from Gemfile inline URIs)
106
- registered = @mutex.synchronize { @registered[host] }
107
+ registered = @mutex.synchronize { @registered[uri.host] }
107
108
  return registered if registered
108
109
 
109
- # 3–4. Config files (scint, then bundler)
110
- key = self.class.key_for_host(host)
111
- val = @file_config[key]
112
- return val if val
110
+ # 3–5. Config files (bundler local, scint, bundler global)
111
+ keys = self.class.keys_for_uri_lookup(uri)
112
+ keys.each do |key|
113
+ val = @file_config[key]
114
+ return val if val
115
+ end
116
+
117
+ # 6. Environment variable
118
+ keys.each do |key|
119
+ val = ENV[key]
120
+ return val if val
121
+ end
113
122
 
114
- # 5. Environment variable
115
- ENV[key]
123
+ nil
116
124
  end
117
125
 
118
126
  def load_config_files
119
127
  config = {}
120
128
  # Load in reverse priority (later overrides earlier)
121
- load_yaml_into(config, bundler_config_path)
129
+ load_yaml_into(config, bundler_global_config_path)
122
130
  load_yaml_into(config, scint_credentials_path)
131
+ load_yaml_into(config, bundler_local_config_path)
123
132
  config
124
133
  end
125
134
 
@@ -137,7 +146,17 @@ module Scint
137
146
  File.join(xdg, "scint", "credentials")
138
147
  end
139
148
 
140
- def bundler_config_path
149
+ def bundler_local_config_path
150
+ app_config = ENV["BUNDLE_APP_CONFIG"]
151
+ dir = if app_config && !app_config.empty?
152
+ app_config
153
+ else
154
+ File.join(Dir.pwd, ".bundle")
155
+ end
156
+ File.join(dir, "config")
157
+ end
158
+
159
+ def bundler_global_config_path
141
160
  File.join(Dir.home, ".bundle", "config")
142
161
  end
143
162
 
@@ -149,5 +168,49 @@ module Scint
149
168
  key.upcase!
150
169
  "BUNDLE_#{key}"
151
170
  end
171
+
172
+ def self.key_for_uri_string(uri_string)
173
+ key = uri_string.to_s.dup
174
+ key.gsub!(".", "__")
175
+ key.gsub!("-", "___")
176
+ key.upcase!
177
+ "BUNDLE_#{key}"
178
+ end
179
+
180
+ def self.key_for_source_uri(uri)
181
+ uri = URI.parse(uri.to_s) unless uri.is_a?(URI)
182
+ keys_for_uri_lookup(uri).find { |key| key != key_for_host(uri.host) }
183
+ rescue StandardError
184
+ nil
185
+ end
186
+
187
+ def self.keys_for_uri_lookup(uri)
188
+ uri = URI.parse(uri.to_s) unless uri.is_a?(URI)
189
+ keys = []
190
+
191
+ normalized = "#{uri.scheme || 'https'}://#{uri.host}"
192
+ default_port = (uri.scheme == "http" ? 80 : 443)
193
+ normalized += ":#{uri.port}" if uri.port && uri.port != default_port
194
+
195
+ path = uri.path.to_s
196
+ path = "/" if path.empty?
197
+ path = "/#{path}" unless path.start_with?("/")
198
+ path += "/" unless path.end_with?("/")
199
+
200
+ segments = path.split("/").reject(&:empty?)
201
+ candidate_paths = ["/"]
202
+ unless segments.empty?
203
+ 1.upto(segments.length) do |i|
204
+ candidate_paths << "/#{segments.first(i).join('/')}/"
205
+ end
206
+ end
207
+
208
+ candidate_paths.reverse_each do |candidate_path|
209
+ keys << key_for_uri_string("#{normalized}#{candidate_path}")
210
+ end
211
+
212
+ keys << key_for_host(uri.host) if uri.host
213
+ keys.compact.uniq
214
+ end
152
215
  end
153
216
  end
@@ -110,7 +110,9 @@ module Scint
110
110
 
111
111
  next unless container.method_defined?(original_name) || container.private_method_defined?(original_name)
112
112
 
113
- container.send(:alias_method, method_name, original_name)
113
+ with_silenced_warnings do
114
+ container.send(:alias_method, method_name, original_name)
115
+ end
114
116
  container.send(:remove_method, original_name)
115
117
 
116
118
  visibility = entry[:visibility]
@@ -146,12 +148,21 @@ module Scint
146
148
  visibility = method_visibility(container, method_name)
147
149
 
148
150
  container.send(:alias_method, original_name, method_name)
149
- container.send(:define_method, method_name) do |*args, **kwargs, &block|
150
- Scint::Debug::IOTrace.log(op_name, args: args, kwargs: kwargs)
151
- if kwargs.empty?
152
- send(original_name, *args, &block)
153
- else
154
- send(original_name, *args, **kwargs, &block)
151
+ if container.instance_methods(false).include?(method_name) ||
152
+ container.private_instance_methods(false).include?(method_name) ||
153
+ container.protected_instance_methods(false).include?(method_name)
154
+ container.send(:remove_method, method_name)
155
+ elsif container.method_defined?(method_name) || container.private_method_defined?(method_name) || container.protected_method_defined?(method_name)
156
+ container.send(:undef_method, method_name)
157
+ end
158
+ with_silenced_warnings do
159
+ container.send(:define_method, method_name) do |*args, **kwargs, &block|
160
+ Scint::Debug::IOTrace.log(op_name, args: args, kwargs: kwargs)
161
+ if kwargs.empty?
162
+ send(original_name, *args, &block)
163
+ else
164
+ send(original_name, *args, **kwargs, &block)
165
+ end
155
166
  end
156
167
  end
157
168
 
@@ -173,6 +184,14 @@ module Scint
173
184
  nil
174
185
  end
175
186
 
187
+ def with_silenced_warnings
188
+ verbose = $VERBOSE
189
+ $VERBOSE = nil
190
+ yield
191
+ ensure
192
+ $VERBOSE = verbose
193
+ end
194
+
176
195
  def disable_unlocked
177
196
  return unless @enabled || @log_io
178
197
 
@@ -54,7 +54,13 @@ module Scint
54
54
  current_uri = URI.parse(location)
55
55
  next
56
56
  else
57
- raise NetworkError, "HTTP #{response.code} for #{uri}: #{response.message}"
57
+ raise NetworkError.new(
58
+ http_error_message(current_uri, response),
59
+ uri: current_uri.to_s,
60
+ http_status: response.code.to_i,
61
+ response_headers: response_headers_hash(response),
62
+ response_body: response.body.to_s,
63
+ )
58
64
  end
59
65
  end
60
66
 
@@ -108,6 +114,24 @@ module Scint
108
114
  http
109
115
  end
110
116
  end
117
+
118
+ def http_error_message(uri, response)
119
+ message = "HTTP #{response.code} for #{uri}: #{response.message}"
120
+ body = response.body.to_s
121
+ return message if body.empty?
122
+
123
+ excerpt = body.gsub(/\s+/, " ").strip
124
+ return message if excerpt.empty?
125
+
126
+ excerpt = "#{excerpt[0, 277]}..." if excerpt.length > 280
127
+ "#{message} -- #{excerpt}"
128
+ end
129
+
130
+ def response_headers_hash(response)
131
+ headers = {}
132
+ response.each_header { |k, v| headers[k] = v }
133
+ headers
134
+ end
111
135
  end
112
136
  end
113
137
  end
@@ -4,41 +4,59 @@ require_relative "fetcher"
4
4
  require_relative "../worker_pool"
5
5
  require_relative "../platform"
6
6
  require_relative "../errors"
7
+ require "uri"
7
8
 
8
9
  module Scint
9
10
  module Downloader
10
11
  class Pool
11
12
  MAX_RETRIES = 3
12
13
  BACKOFF_BASE = 0.5 # seconds
14
+ DEFAULT_PER_HOST_LIMIT = 4
13
15
 
14
16
  attr_reader :size
15
17
 
16
- def initialize(size: nil, on_progress: nil, credentials: nil)
18
+ def initialize(size: nil, on_progress: nil, credentials: nil, per_host_limit: DEFAULT_PER_HOST_LIMIT)
17
19
  @size = size || [Platform.cpu_count * 2, 50].min
18
20
  @on_progress = on_progress
19
21
  @credentials = credentials
22
+ @per_host_limit = [per_host_limit.to_i, 1].max
20
23
  @fetchers = {} # thread_id => Fetcher
21
24
  @fetcher_mutex = Thread::Mutex.new
25
+ @host_slots = Hash.new(0)
26
+ @host_waiters = {}
27
+ @host_mutex = Thread::Mutex.new
22
28
  end
23
29
 
24
30
  # Download a single URI to dest_path with retry logic.
25
31
  # Returns { path:, size: }
26
32
  def download(uri, dest_path, checksum: nil)
27
- retries = 0
28
- begin
29
- fetcher = thread_fetcher
30
- fetcher.fetch(uri, dest_path, checksum: checksum)
31
- rescue NetworkError, Errno::ECONNRESET, Errno::ECONNREFUSED,
32
- Errno::ETIMEDOUT, Net::ReadTimeout, Net::OpenTimeout,
33
- SocketError, IOError => e
34
- retries += 1
35
- if retries <= MAX_RETRIES
36
- sleep(BACKOFF_BASE * (2**(retries - 1)))
37
- # Reset connection on retry
38
- reset_thread_fetcher
39
- retry
33
+ with_host_slot(uri) do
34
+ retries = 0
35
+ begin
36
+ fetcher = thread_fetcher
37
+ fetcher.fetch(uri, dest_path, checksum: checksum)
38
+ rescue NetworkError, Errno::ECONNRESET, Errno::ECONNREFUSED,
39
+ Errno::ETIMEDOUT, Net::ReadTimeout, Net::OpenTimeout,
40
+ SocketError, IOError => e
41
+ retries += 1
42
+ if retries <= MAX_RETRIES
43
+ sleep(BACKOFF_BASE * (2**(retries - 1)))
44
+ # Reset connection on retry
45
+ reset_thread_fetcher
46
+ retry
47
+ end
48
+ if e.is_a?(NetworkError)
49
+ raise NetworkError.new(
50
+ "Failed to download #{uri} after #{MAX_RETRIES} retries: #{e.message}",
51
+ uri: (e.uri || uri.to_s),
52
+ http_status: e.http_status,
53
+ response_headers: e.response_headers,
54
+ response_body: e.response_body,
55
+ )
56
+ end
57
+
58
+ raise NetworkError, "Failed to download #{uri} after #{MAX_RETRIES} retries: #{e.message}"
40
59
  end
41
- raise NetworkError, "Failed to download #{uri} after #{MAX_RETRIES} retries: #{e.message}"
42
60
  end
43
61
  end
44
62
 
@@ -107,6 +125,40 @@ module Scint
107
125
  old&.close
108
126
  end
109
127
  end
128
+
129
+ def with_host_slot(uri)
130
+ key = host_slot_key(uri)
131
+ return yield unless key
132
+
133
+ waiter = nil
134
+ @host_mutex.synchronize do
135
+ waiter = (@host_waiters[key] ||= Thread::ConditionVariable.new)
136
+ while @host_slots[key] >= @per_host_limit
137
+ waiter.wait(@host_mutex)
138
+ end
139
+ @host_slots[key] += 1
140
+ end
141
+
142
+ begin
143
+ yield
144
+ ensure
145
+ @host_mutex.synchronize do
146
+ @host_slots[key] -= 1 if @host_slots[key] > 0
147
+ waiter.broadcast
148
+ end
149
+ end
150
+ end
151
+
152
+ def host_slot_key(uri)
153
+ parsed = uri.is_a?(URI) ? uri : URI.parse(uri.to_s)
154
+ return nil unless parsed.host
155
+
156
+ scheme = parsed.scheme || "https"
157
+ port = parsed.port || (scheme == "https" ? 443 : 80)
158
+ "#{scheme}://#{parsed.host}:#{port}"
159
+ rescue URI::InvalidURIError
160
+ nil
161
+ end
110
162
  end
111
163
  end
112
164
  end
data/lib/scint/errors.rb CHANGED
@@ -26,6 +26,16 @@ module Scint
26
26
  end
27
27
 
28
28
  class NetworkError < BundlerError
29
+ attr_reader :uri, :http_status, :response_headers, :response_body
30
+
31
+ def initialize(message = nil, uri: nil, http_status: nil, response_headers: nil, response_body: nil)
32
+ super(message)
33
+ @uri = uri
34
+ @http_status = http_status
35
+ @response_headers = response_headers
36
+ @response_body = response_body
37
+ end
38
+
29
39
  def status_code
30
40
  7
31
41
  end
data/lib/scint/fs.rb CHANGED
@@ -32,6 +32,11 @@ module Scint
32
32
  return if system("cp", "-c", src, dst, [:out, :err] => File::NULL)
33
33
  end
34
34
 
35
+ # Try Linux reflink copy-on-write where supported (btrfs/xfs/etc).
36
+ if Platform.linux?
37
+ return if system("cp", "--reflink=always", src, dst, [:out, :err] => File::NULL)
38
+ end
39
+
35
40
  # Fallback: hardlink
36
41
  begin
37
42
  File.link(src, dst)
@@ -44,11 +49,73 @@ module Scint
44
49
  FileUtils.cp(src, dst)
45
50
  end
46
51
 
52
+ # Recursively clone directory tree from src_dir into dst_dir.
53
+ # On macOS/APFS, prefers CoW clones via `cp -cR`.
54
+ # Falls back to hardlink_tree, then regular copy per-file if needed.
55
+ def clone_tree(src_dir, dst_dir)
56
+ src_dir = src_dir.to_s
57
+ dst_dir = dst_dir.to_s
58
+ raise Errno::ENOENT, src_dir unless Dir.exist?(src_dir)
59
+ mkdir_p(dst_dir)
60
+
61
+ # Fast path on macOS/APFS: copy-on-write clone of full tree.
62
+ if Platform.macos?
63
+ src_contents = File.join(src_dir, ".")
64
+ return if system("cp", "-cR", src_contents, dst_dir, [:out, :err] => File::NULL)
65
+ end
66
+
67
+ # Fast path on Linux filesystems with reflink support.
68
+ if Platform.linux?
69
+ src_contents = File.join(src_dir, ".")
70
+ return if system("cp", "--reflink=always", "-R", src_contents, dst_dir, [:out, :err] => File::NULL)
71
+ end
72
+
73
+ hardlink_tree(src_dir, dst_dir)
74
+ end
75
+
76
+ # Clone many source directories into one destination parent directory.
77
+ # This is significantly faster than one process per gem on large warm
78
+ # installs because it batches cp invocations while preserving CoW/reflink.
79
+ # Returns the number of source trees requested.
80
+ def clone_many_trees(src_dirs, dst_parent, chunk_size: 64)
81
+ dst_parent = dst_parent.to_s
82
+ mkdir_p(dst_parent)
83
+
84
+ sources = Array(src_dirs).map(&:to_s).uniq
85
+ sources.select! { |src| Dir.exist?(src) }
86
+ return 0 if sources.empty?
87
+
88
+ copied = 0
89
+ sources.each_slice([chunk_size.to_i, 1].max) do |slice|
90
+ pending = slice.reject do |src|
91
+ Dir.exist?(File.join(dst_parent, File.basename(src)))
92
+ end
93
+ next if pending.empty?
94
+
95
+ ok = false
96
+ if Platform.macos?
97
+ ok = system("cp", "-cR", *pending, dst_parent, [:out, :err] => File::NULL)
98
+ elsif Platform.linux?
99
+ ok = system("cp", "--reflink=always", "-R", *pending, dst_parent, [:out, :err] => File::NULL)
100
+ end
101
+
102
+ unless ok
103
+ pending.each do |src|
104
+ clone_tree(src, File.join(dst_parent, File.basename(src)))
105
+ end
106
+ end
107
+
108
+ copied += pending.length
109
+ end
110
+
111
+ copied
112
+ end
113
+
47
114
  # Recursively hardlink all files from src_dir into dst_dir.
48
115
  # Directory structure is recreated; files are hardlinked.
49
116
  def hardlink_tree(src_dir, dst_dir)
50
- src_dir = src_dir.to_s
51
- dst_dir = dst_dir.to_s
117
+ src_dir = File.expand_path(src_dir.to_s)
118
+ dst_dir = File.expand_path(dst_dir.to_s)
52
119
  raise Errno::ENOENT, src_dir unless Dir.exist?(src_dir)
53
120
  mkdir_p(dst_dir)
54
121
 
@@ -59,6 +126,10 @@ module Scint
59
126
  Dir.each_child(src_root) do |entry|
60
127
  src_path = File.join(src_root, entry)
61
128
  dst_path = File.join(dst_root, entry)
129
+
130
+ # Guard against recursive copy when destination is nested under source.
131
+ next if dst_dir == src_path || dst_dir.start_with?("#{src_path}/")
132
+
62
133
  stat = File.lstat(src_path)
63
134
 
64
135
  if stat.directory?
@@ -68,10 +139,26 @@ module Scint
68
139
  end
69
140
 
70
141
  mkdir_p(File.dirname(dst_path))
142
+ # Another worker may have already materialized this file.
143
+ next if File.exist?(dst_path)
144
+
71
145
  begin
72
146
  File.link(src_path, dst_path)
147
+ rescue Errno::EEXIST
148
+ # Lost a race to another concurrent linker; destination is valid.
149
+ next
73
150
  rescue SystemCallError
74
- FileUtils.cp(src_path, dst_path)
151
+ # TOCTOU guard: destination may have appeared after File.link failed.
152
+ next if File.exist?(dst_path)
153
+
154
+ begin
155
+ clonefile(src_path, dst_path)
156
+ rescue StandardError
157
+ # If a concurrent worker created destination in the meantime,
158
+ # treat this as success; otherwise bubble up.
159
+ next if File.exist?(dst_path)
160
+ raise
161
+ end
75
162
  end
76
163
  end
77
164
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "dependency"
4
+ require_relative "../source/path"
4
5
 
5
6
  module Scint
6
7
  module Gemfile
@@ -134,6 +135,11 @@ module Scint
134
135
  source_opts[:path] = path_val
135
136
  end
136
137
 
138
+ # Internal/source metadata used by lockfile generation.
139
+ source_opts[:glob] = options.delete(:glob) if options.key?(:glob)
140
+ source_opts[:gemspec_generated] = options.delete(:gemspec_generated) if options.key?(:gemspec_generated)
141
+ source_opts[:gemspec_primary] = options.delete(:gemspec_primary) if options.key?(:gemspec_primary)
142
+
137
143
  if options[:source]
138
144
  source_opts[:source] = options.delete(:source)
139
145
  end
@@ -213,24 +219,39 @@ module Scint
213
219
  instance_eval(contents, expanded, 1)
214
220
  end
215
221
 
216
- def ruby(version, **opts)
217
- @ruby_version = version.to_s
222
+ def ruby(*versions, **opts)
223
+ version_parts = versions.flatten.compact.map(&:to_s)
224
+ @ruby_version = version_parts.join(", ") unless version_parts.empty?
218
225
  end
219
226
 
220
227
  def gemspec(opts = {})
221
228
  path = opts[:path] || "."
222
229
  name = opts[:name]
230
+ glob = opts[:glob] || Scint::Source::Path::DEFAULT_GLOB
223
231
  dir = File.expand_path(path, File.dirname(@gemfile_path))
224
- gemspecs = Dir.glob(File.join(dir, "{,*}.gemspec"))
232
+ gemspecs = Dir.glob(File.join(dir, glob)).sort
225
233
  # Just record we have a gemspec source -- full spec loading is
226
234
  # deferred to the resolver/installer.
227
235
  gemspecs.each do |gs|
228
236
  spec_name = File.basename(gs, ".gemspec")
229
237
  next if name && spec_name != name
230
- gem(spec_name, path: dir)
238
+ gem(
239
+ spec_name,
240
+ path: File.dirname(gs),
241
+ glob: glob,
242
+ gemspec_generated: true,
243
+ gemspec_primary: File.expand_path(File.dirname(gs)) == dir,
244
+ )
231
245
  end
232
246
  end
233
247
 
248
+ def install_if(*conditions, &blk)
249
+ raise GemfileError, "install_if requires a block" unless block_given?
250
+ return unless conditions.all? { |condition| condition_truthy?(condition) }
251
+
252
+ yield
253
+ end
254
+
234
255
  # Silently ignore plugin declarations
235
256
  def plugin(*args); end
236
257
 
@@ -247,6 +268,12 @@ module Scint
247
268
 
248
269
  private
249
270
 
271
+ def condition_truthy?(condition)
272
+ return condition.call if condition.respond_to?(:call)
273
+
274
+ !!condition
275
+ end
276
+
250
277
  def add_default_git_sources
251
278
  git_source(:github) do |repo_name|
252
279
  if repo_name =~ %r{\Ahttps://github\.com/([^/]+/[^/]+)/pull/(\d+)\z}
@@ -12,7 +12,7 @@ module Scint
12
12
  # Thread-safe. Uses ETag/Range for efficient updates.
13
13
  class Client
14
14
  ACCEPT_ENCODING = "gzip"
15
- USER_AGENT = "scint/0.1.0"
15
+ USER_AGENT = "scint/#{Scint::VERSION}"
16
16
  DEFAULT_TIMEOUT = 15
17
17
 
18
18
  attr_reader :source_uri