scint 0.6.0 → 0.7.1
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.
- checksums.yaml +4 -4
- data/FEATURES.md +4 -0
- data/README.md +161 -41
- data/VERSION +1 -1
- data/bin/scint +9 -0
- data/lib/bundler.rb +106 -0
- data/lib/scint/cache/layout.rb +72 -14
- data/lib/scint/cache/manifest.rb +120 -0
- data/lib/scint/cache/metadata_store.rb +4 -11
- data/lib/scint/cache/prewarm.rb +445 -33
- data/lib/scint/cache/validity.rb +134 -0
- data/lib/scint/cli/cache.rb +36 -7
- data/lib/scint/cli/exec.rb +13 -25
- data/lib/scint/cli/install.rb +1452 -164
- data/lib/scint/credentials.rb +78 -15
- data/lib/scint/debug/io_trace.rb +26 -7
- data/lib/scint/downloader/fetcher.rb +25 -1
- data/lib/scint/downloader/pool.rb +67 -15
- data/lib/scint/errors.rb +10 -0
- data/lib/scint/fs.rb +215 -26
- data/lib/scint/gem/package.rb +6 -2
- data/lib/scint/gemfile/parser.rb +44 -10
- data/lib/scint/installer/extension_builder.rb +80 -55
- data/lib/scint/installer/linker.rb +51 -26
- data/lib/scint/installer/planner.rb +53 -34
- data/lib/scint/installer/preparer.rb +170 -47
- data/lib/scint/installer/promoter.rb +97 -0
- data/lib/scint/linker.sh +137 -0
- data/lib/scint/lockfile/parser.rb +2 -1
- data/lib/scint/lockfile/writer.rb +85 -36
- data/lib/scint/platform.rb +8 -0
- data/lib/scint/resolver/provider.rb +15 -2
- data/lib/scint/runtime/exec.rb +52 -26
- data/lib/scint/runtime/setup.rb +29 -1
- data/lib/scint/scheduler.rb +6 -1
- data/lib/scint/spec_utils.rb +133 -0
- data/lib/scint.rb +1 -0
- metadata +6 -1
data/lib/scint/credentials.rb
CHANGED
|
@@ -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.
|
|
17
|
-
# 4.
|
|
18
|
-
# 5.
|
|
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 =
|
|
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
|
|
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–
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
data/lib/scint/debug/io_trace.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
@@ -21,28 +21,75 @@ module Scint
|
|
|
21
21
|
@mkdir_mutex.synchronize { @mkdir_cache[path] = true }
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# Detect best file copy strategy once per process.
|
|
25
|
+
# Returns :reflink, :hardlink, or :copy.
|
|
26
|
+
@copy_strategy = nil
|
|
27
|
+
@copy_strategy_mutex = Thread::Mutex.new
|
|
28
|
+
|
|
29
|
+
def detect_copy_strategy(src_dir, dst_dir)
|
|
30
|
+
@copy_strategy_mutex.synchronize do
|
|
31
|
+
return @copy_strategy if @copy_strategy
|
|
32
|
+
|
|
33
|
+
@copy_strategy = _probe_copy_strategy(src_dir, dst_dir)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def _probe_copy_strategy(src_dir, dst_dir)
|
|
38
|
+
# Create a temp file in src to test against dst
|
|
39
|
+
probe_src = File.join(src_dir, ".scint_probe_#{$$}")
|
|
40
|
+
probe_dst = File.join(dst_dir, ".scint_probe_#{$$}")
|
|
41
|
+
begin
|
|
42
|
+
File.write(probe_src, "x")
|
|
43
|
+
mkdir_p(dst_dir)
|
|
44
|
+
|
|
45
|
+
if Platform.macos?
|
|
46
|
+
if system("cp", "-c", probe_src, probe_dst, [:out, :err] => File::NULL)
|
|
47
|
+
return :reflink
|
|
48
|
+
end
|
|
49
|
+
File.delete(probe_dst) if File.exist?(probe_dst)
|
|
50
|
+
elsif Platform.linux?
|
|
51
|
+
if system("cp", "--reflink=always", probe_src, probe_dst, [:out, :err] => File::NULL)
|
|
52
|
+
return :reflink
|
|
53
|
+
end
|
|
54
|
+
File.delete(probe_dst) if File.exist?(probe_dst)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
File.link(probe_src, probe_dst)
|
|
59
|
+
return :hardlink
|
|
60
|
+
rescue SystemCallError
|
|
61
|
+
File.delete(probe_dst) if File.exist?(probe_dst)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
:copy
|
|
65
|
+
ensure
|
|
66
|
+
File.delete(probe_src) if File.exist?(probe_src)
|
|
67
|
+
File.delete(probe_dst) if File.exist?(probe_dst)
|
|
68
|
+
end
|
|
69
|
+
rescue StandardError
|
|
70
|
+
:copy
|
|
71
|
+
end
|
|
72
|
+
|
|
24
73
|
# APFS clonefile (CoW copy). Falls back to hardlink, then regular copy.
|
|
25
74
|
def clonefile(src, dst)
|
|
26
75
|
src = src.to_s
|
|
27
76
|
dst = dst.to_s
|
|
28
77
|
mkdir_p(File.dirname(dst))
|
|
29
78
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
rescue SystemCallError
|
|
45
|
-
# cross-device or unsupported
|
|
79
|
+
case detect_copy_strategy(File.dirname(src), File.dirname(dst))
|
|
80
|
+
when :reflink
|
|
81
|
+
if Platform.macos?
|
|
82
|
+
return if system("cp", "-c", src, dst, [:out, :err] => File::NULL)
|
|
83
|
+
elsif Platform.linux?
|
|
84
|
+
return if system("cp", "--reflink=always", src, dst, [:out, :err] => File::NULL)
|
|
85
|
+
end
|
|
86
|
+
when :hardlink
|
|
87
|
+
begin
|
|
88
|
+
File.link(src, dst)
|
|
89
|
+
return
|
|
90
|
+
rescue SystemCallError
|
|
91
|
+
# fall through to copy
|
|
92
|
+
end
|
|
46
93
|
end
|
|
47
94
|
|
|
48
95
|
# Final fallback: regular copy
|
|
@@ -58,26 +105,164 @@ module Scint
|
|
|
58
105
|
raise Errno::ENOENT, src_dir unless Dir.exist?(src_dir)
|
|
59
106
|
mkdir_p(dst_dir)
|
|
60
107
|
|
|
61
|
-
|
|
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
|
|
108
|
+
strategy = detect_copy_strategy(src_dir, dst_dir)
|
|
66
109
|
|
|
67
|
-
|
|
68
|
-
if Platform.linux?
|
|
110
|
+
if strategy == :reflink
|
|
69
111
|
src_contents = File.join(src_dir, ".")
|
|
70
|
-
|
|
112
|
+
if Platform.macos?
|
|
113
|
+
return if system("cp", "-cR", src_contents, dst_dir, [:out, :err] => File::NULL)
|
|
114
|
+
elsif Platform.linux?
|
|
115
|
+
return if system("cp", "--reflink=always", "-R", src_contents, dst_dir, [:out, :err] => File::NULL)
|
|
116
|
+
end
|
|
71
117
|
end
|
|
72
118
|
|
|
73
119
|
hardlink_tree(src_dir, dst_dir)
|
|
74
120
|
end
|
|
75
121
|
|
|
122
|
+
# Materialize a tree using a manifest to avoid directory scans.
|
|
123
|
+
# Manifest entries must be hashes with "path" and "type" keys.
|
|
124
|
+
# Uses the fastest available file copy strategy (reflink > hardlink > copy).
|
|
125
|
+
def materialize_from_manifest(src_dir, dst_dir, entries)
|
|
126
|
+
src_dir = src_dir.to_s
|
|
127
|
+
dst_dir = dst_dir.to_s
|
|
128
|
+
entries = Array(entries)
|
|
129
|
+
raise Errno::ENOENT, src_dir unless Dir.exist?(src_dir)
|
|
130
|
+
mkdir_p(dst_dir)
|
|
131
|
+
|
|
132
|
+
strategy = detect_copy_strategy(src_dir, dst_dir)
|
|
133
|
+
|
|
134
|
+
entries.each do |entry|
|
|
135
|
+
rel = entry["path"].to_s
|
|
136
|
+
next if rel.empty? || rel.start_with?("/") || rel.include?("..")
|
|
137
|
+
|
|
138
|
+
src_path = File.join(src_dir, rel)
|
|
139
|
+
dst_path = File.join(dst_dir, rel)
|
|
140
|
+
|
|
141
|
+
case entry["type"]
|
|
142
|
+
when "dir"
|
|
143
|
+
mkdir_p(dst_path)
|
|
144
|
+
when "symlink"
|
|
145
|
+
mkdir_p(File.dirname(dst_path))
|
|
146
|
+
next if File.exist?(dst_path) || File.symlink?(dst_path)
|
|
147
|
+
|
|
148
|
+
target = File.readlink(src_path)
|
|
149
|
+
begin
|
|
150
|
+
File.symlink(target, dst_path)
|
|
151
|
+
rescue Errno::EEXIST
|
|
152
|
+
next
|
|
153
|
+
end
|
|
154
|
+
else
|
|
155
|
+
mkdir_p(File.dirname(dst_path))
|
|
156
|
+
next if File.exist?(dst_path)
|
|
157
|
+
|
|
158
|
+
begin
|
|
159
|
+
_link_or_copy(src_path, dst_path, strategy)
|
|
160
|
+
rescue Errno::EEXIST
|
|
161
|
+
next
|
|
162
|
+
rescue SystemCallError
|
|
163
|
+
next if File.exist?(dst_path)
|
|
164
|
+
raise
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Fast file link/copy using a pre-detected strategy (no per-file probing).
|
|
171
|
+
def _link_or_copy(src, dst, strategy)
|
|
172
|
+
case strategy
|
|
173
|
+
when :reflink
|
|
174
|
+
if Platform.macos?
|
|
175
|
+
return if system("cp", "-c", src, dst, [:out, :err] => File::NULL)
|
|
176
|
+
elsif Platform.linux?
|
|
177
|
+
return if system("cp", "--reflink=always", src, dst, [:out, :err] => File::NULL)
|
|
178
|
+
end
|
|
179
|
+
# reflink failed for this file, try hardlink
|
|
180
|
+
begin
|
|
181
|
+
File.link(src, dst)
|
|
182
|
+
return
|
|
183
|
+
rescue SystemCallError; end
|
|
184
|
+
FileUtils.cp(src, dst)
|
|
185
|
+
when :hardlink
|
|
186
|
+
begin
|
|
187
|
+
File.link(src, dst)
|
|
188
|
+
return
|
|
189
|
+
rescue SystemCallError; end
|
|
190
|
+
FileUtils.cp(src, dst)
|
|
191
|
+
else
|
|
192
|
+
FileUtils.cp(src, dst)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
LINKER_SCRIPT = File.expand_path("linker.sh", __dir__).freeze
|
|
197
|
+
|
|
198
|
+
# Bulk-link cached gem directories into dst_parent.
|
|
199
|
+
# Opens one helper process (linker.sh) and writes gem basenames to its
|
|
200
|
+
# stdin. The helper probes the fastest FS strategy once then applies it
|
|
201
|
+
# to every gem.
|
|
202
|
+
def bulk_link_gems(src_parent, dst_parent, gem_names)
|
|
203
|
+
src_parent = src_parent.to_s
|
|
204
|
+
dst_parent = dst_parent.to_s
|
|
205
|
+
gem_names = Array(gem_names)
|
|
206
|
+
return 0 if gem_names.empty?
|
|
207
|
+
|
|
208
|
+
mkdir_p(dst_parent)
|
|
209
|
+
|
|
210
|
+
IO.popen(["/bin/bash", LINKER_SCRIPT], "w") do |io|
|
|
211
|
+
io.puts src_parent
|
|
212
|
+
io.puts dst_parent
|
|
213
|
+
gem_names.each { |name| io.puts name }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
gem_names.size
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Clone many source directories into one destination parent directory.
|
|
220
|
+
# This is significantly faster than one process per gem on large warm
|
|
221
|
+
# installs because it batches cp invocations while preserving CoW/reflink.
|
|
222
|
+
# Returns the number of source trees requested.
|
|
223
|
+
def clone_many_trees(src_dirs, dst_parent, chunk_size: 64)
|
|
224
|
+
dst_parent = dst_parent.to_s
|
|
225
|
+
mkdir_p(dst_parent)
|
|
226
|
+
|
|
227
|
+
sources = Array(src_dirs).map(&:to_s).uniq
|
|
228
|
+
sources.select! { |src| Dir.exist?(src) }
|
|
229
|
+
return 0 if sources.empty?
|
|
230
|
+
|
|
231
|
+
copied = 0
|
|
232
|
+
strategy = sources.first ? detect_copy_strategy(sources.first, dst_parent) : :copy
|
|
233
|
+
|
|
234
|
+
sources.each_slice([chunk_size.to_i, 1].max) do |slice|
|
|
235
|
+
pending = slice.reject do |src|
|
|
236
|
+
Dir.exist?(File.join(dst_parent, File.basename(src)))
|
|
237
|
+
end
|
|
238
|
+
next if pending.empty?
|
|
239
|
+
|
|
240
|
+
ok = false
|
|
241
|
+
if strategy == :reflink
|
|
242
|
+
if Platform.macos?
|
|
243
|
+
ok = system("cp", "-cR", *pending, dst_parent, [:out, :err] => File::NULL)
|
|
244
|
+
elsif Platform.linux?
|
|
245
|
+
ok = system("cp", "--reflink=always", "-R", *pending, dst_parent, [:out, :err] => File::NULL)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
unless ok
|
|
250
|
+
pending.each do |src|
|
|
251
|
+
clone_tree(src, File.join(dst_parent, File.basename(src)))
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
copied += pending.length
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
copied
|
|
259
|
+
end
|
|
260
|
+
|
|
76
261
|
# Recursively hardlink all files from src_dir into dst_dir.
|
|
77
262
|
# Directory structure is recreated; files are hardlinked.
|
|
78
263
|
def hardlink_tree(src_dir, dst_dir)
|
|
79
|
-
src_dir = src_dir.to_s
|
|
80
|
-
dst_dir = dst_dir.to_s
|
|
264
|
+
src_dir = File.expand_path(src_dir.to_s)
|
|
265
|
+
dst_dir = File.expand_path(dst_dir.to_s)
|
|
81
266
|
raise Errno::ENOENT, src_dir unless Dir.exist?(src_dir)
|
|
82
267
|
mkdir_p(dst_dir)
|
|
83
268
|
|
|
@@ -88,6 +273,10 @@ module Scint
|
|
|
88
273
|
Dir.each_child(src_root) do |entry|
|
|
89
274
|
src_path = File.join(src_root, entry)
|
|
90
275
|
dst_path = File.join(dst_root, entry)
|
|
276
|
+
|
|
277
|
+
# Guard against recursive copy when destination is nested under source.
|
|
278
|
+
next if dst_dir == src_path || dst_dir.start_with?("#{src_path}/")
|
|
279
|
+
|
|
91
280
|
stat = File.lstat(src_path)
|
|
92
281
|
|
|
93
282
|
if stat.directory?
|
data/lib/scint/gem/package.rb
CHANGED
|
@@ -17,7 +17,9 @@ module Scint
|
|
|
17
17
|
tar.each do |entry|
|
|
18
18
|
if entry.full_name == "metadata.gz"
|
|
19
19
|
gz = Zlib::GzipReader.new(StringIO.new(entry.read))
|
|
20
|
-
|
|
20
|
+
yaml = gz.read
|
|
21
|
+
yaml.force_encoding("UTF-8") if yaml.encoding == Encoding::US_ASCII
|
|
22
|
+
return ::Gem::Specification.from_yaml(yaml)
|
|
21
23
|
end
|
|
22
24
|
end
|
|
23
25
|
end
|
|
@@ -38,7 +40,9 @@ module Scint
|
|
|
38
40
|
case entry.full_name
|
|
39
41
|
when "metadata.gz"
|
|
40
42
|
gz = Zlib::GzipReader.new(StringIO.new(entry.read))
|
|
41
|
-
|
|
43
|
+
yaml = gz.read
|
|
44
|
+
yaml.force_encoding("UTF-8") if yaml.encoding == Encoding::US_ASCII
|
|
45
|
+
gemspec = ::Gem::Specification.from_yaml(yaml)
|
|
42
46
|
when "data.tar.gz"
|
|
43
47
|
# Write data.tar.gz to a temp file for extraction
|
|
44
48
|
tmp = File.join(dest_dir, ".data.tar.gz.tmp")
|