scint 0.7.0 → 0.8.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.
- checksums.yaml +4 -4
- data/FEATURES.md +4 -0
- data/README.md +142 -198
- data/VERSION +1 -1
- data/lib/scint/cache/layout.rb +66 -5
- data/lib/scint/cache/manifest.rb +120 -0
- data/lib/scint/cache/prewarm.rb +445 -33
- data/lib/scint/cache/validity.rb +134 -0
- data/lib/scint/cli/cache.rb +34 -6
- data/lib/scint/cli/exec.rb +1 -1
- data/lib/scint/cli/install.rb +611 -292
- data/lib/scint/fs.rb +175 -28
- data/lib/scint/gem/package.rb +6 -2
- data/lib/scint/gemfile/parser.rb +13 -6
- data/lib/scint/index/client.rb +13 -2
- data/lib/scint/installer/extension_builder.rb +63 -43
- data/lib/scint/installer/linker.rb +43 -2
- data/lib/scint/installer/planner.rb +24 -28
- data/lib/scint/installer/preparer.rb +167 -37
- data/lib/scint/installer/promoter.rb +97 -0
- data/lib/scint/linker.sh +137 -0
- data/lib/scint/spec_utils.rb +79 -0
- data/lib/scint.rb +12 -4
- metadata +5 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "extension_builder"
|
|
4
4
|
require_relative "../platform"
|
|
5
|
+
require_relative "../cache/validity"
|
|
5
6
|
|
|
6
7
|
module Scint
|
|
7
8
|
module Installer
|
|
@@ -12,16 +13,16 @@ module Scint
|
|
|
12
13
|
# Compare resolved specs against what's already installed.
|
|
13
14
|
# Returns an Array of PlanEntry with action set to one of:
|
|
14
15
|
# :skip — already installed in bundle_path
|
|
15
|
-
# :link —
|
|
16
|
+
# :link — cached in global cache, just needs linking
|
|
16
17
|
# :download — needs downloading from remote
|
|
17
18
|
# :build_ext — has native extensions that need compiling
|
|
18
19
|
#
|
|
19
20
|
# Download entries are sorted largest-first so big gems start early,
|
|
20
21
|
# keeping the pipeline saturated while small gems fill in gaps.
|
|
21
|
-
def plan(resolved_specs, bundle_path, cache_layout)
|
|
22
|
+
def plan(resolved_specs, bundle_path, cache_layout, telemetry: nil)
|
|
22
23
|
ruby_dir = Platform.ruby_install_dir(bundle_path)
|
|
23
24
|
entries = resolved_specs.map do |spec|
|
|
24
|
-
plan_one(spec, ruby_dir, cache_layout)
|
|
25
|
+
plan_one(spec, ruby_dir, cache_layout, telemetry: telemetry)
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
# Keep built-ins first, then downloads (big->small), then the rest.
|
|
@@ -32,7 +33,7 @@ module Scint
|
|
|
32
33
|
builtins + downloads + rest
|
|
33
34
|
end
|
|
34
35
|
|
|
35
|
-
def plan_one(spec, ruby_dir, cache_layout)
|
|
36
|
+
def plan_one(spec, ruby_dir, cache_layout, telemetry: nil)
|
|
36
37
|
full = cache_layout.full_name(spec)
|
|
37
38
|
gem_path = File.join(ruby_dir, "gems", full)
|
|
38
39
|
spec_path = File.join(ruby_dir, "specifications", "#{full}.gemspec")
|
|
@@ -48,10 +49,12 @@ module Scint
|
|
|
48
49
|
|
|
49
50
|
# Already installed? Require both gem files and specification.
|
|
50
51
|
if Dir.exist?(gem_path) && File.exist?(spec_path)
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
cache_source = Cache::Validity.source_path_for(spec, cache_layout, telemetry: telemetry)
|
|
53
|
+
if extension_link_missing?(spec, ruby_dir, cache_layout, cache_source)
|
|
53
54
|
action = ExtensionBuilder.cached_build_available?(spec, cache_layout) ? :link : :build_ext
|
|
54
|
-
return PlanEntry.new(spec: spec, action: action, cached_path:
|
|
55
|
+
return PlanEntry.new(spec: spec, action: action, cached_path: cache_source, gem_path: gem_path) if cache_source
|
|
56
|
+
|
|
57
|
+
return PlanEntry.new(spec: spec, action: :download, cached_path: nil, gem_path: gem_path)
|
|
55
58
|
end
|
|
56
59
|
|
|
57
60
|
return PlanEntry.new(spec: spec, action: :skip, cached_path: nil, gem_path: gem_path)
|
|
@@ -64,39 +67,32 @@ module Scint
|
|
|
64
67
|
return PlanEntry.new(spec: spec, action: action, cached_path: local_source, gem_path: gem_path)
|
|
65
68
|
end
|
|
66
69
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return PlanEntry.new(spec: spec, action: action, cached_path: extracted, gem_path: gem_path)
|
|
70
|
+
cache_source = Cache::Validity.source_path_for(spec, cache_layout, telemetry: telemetry)
|
|
71
|
+
if cache_source
|
|
72
|
+
action = needs_ext_build?(spec, cache_layout, cache_source) ? :build_ext : :link
|
|
73
|
+
return PlanEntry.new(spec: spec, action: action, cached_path: cache_source, gem_path: gem_path)
|
|
72
74
|
end
|
|
73
75
|
|
|
74
76
|
# Needs downloading
|
|
75
77
|
PlanEntry.new(spec: spec, action: :download, cached_path: nil, gem_path: gem_path)
|
|
76
78
|
end
|
|
77
79
|
|
|
78
|
-
def needs_ext_build?(spec, cache_layout)
|
|
79
|
-
|
|
80
|
-
return false unless ExtensionBuilder.needs_build?(spec,
|
|
80
|
+
def needs_ext_build?(spec, cache_layout, source_dir)
|
|
81
|
+
return false unless source_dir
|
|
82
|
+
return false unless ExtensionBuilder.needs_build?(spec, source_dir)
|
|
81
83
|
|
|
82
84
|
!ExtensionBuilder.cached_build_available?(spec, cache_layout)
|
|
83
85
|
end
|
|
84
86
|
|
|
85
|
-
def extension_link_missing?(spec, ruby_dir, cache_layout)
|
|
86
|
-
|
|
87
|
-
return false unless
|
|
88
|
-
return false unless ExtensionBuilder.needs_build?(spec, extracted)
|
|
87
|
+
def extension_link_missing?(spec, ruby_dir, cache_layout, source_dir)
|
|
88
|
+
return false unless source_dir
|
|
89
|
+
return false unless ExtensionBuilder.needs_build?(spec, source_dir)
|
|
89
90
|
|
|
90
91
|
full = cache_layout.full_name(spec)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
Platform.extension_api_version,
|
|
96
|
-
full,
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
!Dir.exist?(ext_install_dir)
|
|
92
|
+
gem_dir = File.join(ruby_dir, "gems", full)
|
|
93
|
+
marker = File.join(gem_dir, ExtensionBuilder::BUILD_MARKER)
|
|
94
|
+
|
|
95
|
+
!File.exist?(marker)
|
|
100
96
|
end
|
|
101
97
|
|
|
102
98
|
# Rough size estimate for download ordering.
|
|
@@ -4,9 +4,16 @@ require_relative "../downloader/pool"
|
|
|
4
4
|
require_relative "../gem/package"
|
|
5
5
|
require_relative "../gem/extractor"
|
|
6
6
|
require_relative "../cache/layout"
|
|
7
|
+
require_relative "../cache/manifest"
|
|
8
|
+
require_relative "../cache/validity"
|
|
7
9
|
require_relative "../fs"
|
|
8
10
|
require_relative "../errors"
|
|
9
11
|
require_relative "../spec_utils"
|
|
12
|
+
require_relative "../platform"
|
|
13
|
+
require_relative "./promoter"
|
|
14
|
+
require_relative "./extension_builder"
|
|
15
|
+
require_relative "../source/git"
|
|
16
|
+
require_relative "../source/path"
|
|
10
17
|
|
|
11
18
|
module Scint
|
|
12
19
|
module Installer
|
|
@@ -45,21 +52,30 @@ module Scint
|
|
|
45
52
|
already_cached = []
|
|
46
53
|
|
|
47
54
|
sorted.each do |entry|
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
spec = entry.spec
|
|
56
|
+
inbound = @layout.inbound_path(spec)
|
|
57
|
+
assembling = @layout.assembling_path(spec)
|
|
58
|
+
cached = @layout.cached_path(spec)
|
|
50
59
|
|
|
51
|
-
if
|
|
52
|
-
|
|
53
|
-
gemspec = load_cached_spec(entry.spec) || read_gemspec_from_extracted(extracted, entry.spec)
|
|
60
|
+
if Cache::Validity.cached_valid?(spec, @layout)
|
|
61
|
+
gemspec = load_cached_spec(spec) || read_gemspec_from_extracted(cached, spec)
|
|
54
62
|
already_cached << PreparedGem.new(
|
|
55
|
-
spec:
|
|
56
|
-
extracted_path:
|
|
63
|
+
spec: spec,
|
|
64
|
+
extracted_path: cached,
|
|
57
65
|
gemspec: gemspec,
|
|
58
66
|
from_cache: true,
|
|
59
67
|
)
|
|
68
|
+
elsif File.directory?(assembling)
|
|
69
|
+
gemspec = read_gemspec_from_extracted(assembling, spec)
|
|
70
|
+
already_cached << PreparedGem.new(
|
|
71
|
+
spec: spec,
|
|
72
|
+
extracted_path: assembling,
|
|
73
|
+
gemspec: gemspec,
|
|
74
|
+
from_cache: false,
|
|
75
|
+
)
|
|
60
76
|
elsif File.exist?(inbound)
|
|
61
77
|
# Downloaded but not extracted
|
|
62
|
-
already_cached << extract_gem(
|
|
78
|
+
already_cached << extract_gem(spec, inbound)
|
|
63
79
|
else
|
|
64
80
|
# Need to download
|
|
65
81
|
to_download << entry
|
|
@@ -105,65 +121,98 @@ module Scint
|
|
|
105
121
|
|
|
106
122
|
# Prepare a single entry (for use with scheduler).
|
|
107
123
|
def prepare_one(entry)
|
|
108
|
-
|
|
109
|
-
|
|
124
|
+
spec = entry.spec
|
|
125
|
+
inbound = @layout.inbound_path(spec)
|
|
126
|
+
assembling = @layout.assembling_path(spec)
|
|
127
|
+
cached = @layout.cached_path(spec)
|
|
110
128
|
|
|
111
|
-
if
|
|
112
|
-
gemspec = load_cached_spec(
|
|
129
|
+
if Cache::Validity.cached_valid?(spec, @layout)
|
|
130
|
+
gemspec = load_cached_spec(spec) || read_gemspec_from_extracted(cached, spec)
|
|
113
131
|
return PreparedGem.new(
|
|
114
|
-
spec:
|
|
115
|
-
extracted_path:
|
|
132
|
+
spec: spec,
|
|
133
|
+
extracted_path: cached,
|
|
116
134
|
gemspec: gemspec,
|
|
117
135
|
from_cache: true,
|
|
118
136
|
)
|
|
119
137
|
end
|
|
120
138
|
|
|
139
|
+
if File.directory?(assembling)
|
|
140
|
+
gemspec = read_gemspec_from_extracted(assembling, spec)
|
|
141
|
+
return PreparedGem.new(
|
|
142
|
+
spec: spec,
|
|
143
|
+
extracted_path: assembling,
|
|
144
|
+
gemspec: gemspec,
|
|
145
|
+
from_cache: false,
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
|
|
121
150
|
unless File.exist?(inbound)
|
|
122
151
|
uri = gem_download_uri(entry)
|
|
123
152
|
@download_pool.download(uri, inbound)
|
|
124
153
|
end
|
|
125
154
|
|
|
126
|
-
extract_gem(
|
|
155
|
+
extract_gem(spec, inbound)
|
|
127
156
|
end
|
|
128
157
|
|
|
129
158
|
private
|
|
130
159
|
|
|
131
160
|
def extract_gem(spec, gem_path)
|
|
132
|
-
|
|
161
|
+
cached = @layout.cached_path(spec)
|
|
162
|
+
assembling = @layout.assembling_path(spec)
|
|
163
|
+
|
|
164
|
+
if Cache::Validity.cached_valid?(spec, @layout)
|
|
165
|
+
gemspec = load_cached_spec(spec) || read_gemspec_from_extracted(cached, spec)
|
|
166
|
+
return PreparedGem.new(spec: spec, extracted_path: cached, gemspec: gemspec, from_cache: true)
|
|
167
|
+
end
|
|
133
168
|
|
|
134
|
-
if File.directory?(
|
|
135
|
-
gemspec =
|
|
136
|
-
return PreparedGem.new(spec: spec, extracted_path:
|
|
169
|
+
if File.directory?(assembling)
|
|
170
|
+
gemspec = read_gemspec_from_extracted(assembling, spec)
|
|
171
|
+
return PreparedGem.new(spec: spec, extracted_path: assembling, gemspec: gemspec, from_cache: false)
|
|
137
172
|
end
|
|
138
173
|
|
|
139
|
-
# Extract to temp dir, then atomic move
|
|
140
|
-
tmp_dest = "#{
|
|
174
|
+
# Extract to temp dir in assembling, then atomic move
|
|
175
|
+
tmp_dest = "#{assembling}.#{Process.pid}.tmp"
|
|
141
176
|
FileUtils.rm_rf(tmp_dest) if File.exist?(tmp_dest)
|
|
142
177
|
|
|
143
178
|
result = @package.extract(gem_path, tmp_dest)
|
|
144
|
-
FS.atomic_move(tmp_dest,
|
|
145
|
-
|
|
146
|
-
# Cache the gemspec as Marshal for fast future loads
|
|
147
|
-
cache_spec(spec, result[:gemspec])
|
|
179
|
+
FS.atomic_move(tmp_dest, assembling)
|
|
148
180
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
181
|
+
if ExtensionBuilder.needs_build?(spec, assembling)
|
|
182
|
+
PreparedGem.new(
|
|
183
|
+
spec: spec,
|
|
184
|
+
extracted_path: assembling,
|
|
185
|
+
gemspec: result[:gemspec],
|
|
186
|
+
from_cache: false,
|
|
187
|
+
)
|
|
188
|
+
else
|
|
189
|
+
promote_assembled(spec, assembling, result[:gemspec])
|
|
190
|
+
PreparedGem.new(
|
|
191
|
+
spec: spec,
|
|
192
|
+
extracted_path: @layout.cached_path(spec),
|
|
193
|
+
gemspec: result[:gemspec],
|
|
194
|
+
from_cache: false,
|
|
195
|
+
)
|
|
196
|
+
end
|
|
155
197
|
end
|
|
156
198
|
|
|
157
199
|
def load_cached_spec(spec)
|
|
158
|
-
path = @layout.
|
|
200
|
+
path = @layout.cached_spec_path(spec)
|
|
159
201
|
return nil unless File.exist?(path)
|
|
160
|
-
|
|
161
|
-
|
|
202
|
+
|
|
203
|
+
data = File.binread(path)
|
|
204
|
+
if data.start_with?("---")
|
|
205
|
+
data.force_encoding("UTF-8") if data.encoding != Encoding::UTF_8
|
|
206
|
+
return Gem::Specification.from_yaml(data)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
Marshal.load(data)
|
|
210
|
+
rescue ArgumentError, TypeError, EOFError, StandardError
|
|
162
211
|
nil
|
|
163
212
|
end
|
|
164
213
|
|
|
165
214
|
def cache_spec(spec, gemspec)
|
|
166
|
-
path = @layout.
|
|
215
|
+
path = @layout.cached_spec_path(spec)
|
|
167
216
|
FS.atomic_write(path, Marshal.dump(gemspec))
|
|
168
217
|
rescue StandardError
|
|
169
218
|
# Non-fatal: cache miss on next load
|
|
@@ -173,10 +222,91 @@ module Scint
|
|
|
173
222
|
pattern = File.join(extracted_dir, "*.gemspec")
|
|
174
223
|
candidates = Dir.glob(pattern)
|
|
175
224
|
if candidates.any?
|
|
225
|
+
version = spec.respond_to?(:version) ? spec.version.to_s : nil
|
|
226
|
+
old_version = ENV["VERSION"]
|
|
176
227
|
begin
|
|
177
|
-
|
|
178
|
-
|
|
228
|
+
ENV["VERSION"] = version if version && !ENV["VERSION"]
|
|
229
|
+
SpecUtils.load_gemspec(candidates.first, isolate: true)
|
|
230
|
+
rescue SystemExit, StandardError
|
|
179
231
|
nil
|
|
232
|
+
ensure
|
|
233
|
+
ENV["VERSION"] = old_version
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def promote_assembled(spec, assembling_path, gemspec)
|
|
239
|
+
return unless assembling_path && Dir.exist?(assembling_path)
|
|
240
|
+
|
|
241
|
+
cached_dir = @layout.cached_path(spec)
|
|
242
|
+
promoter = Promoter.new(root: @layout.root)
|
|
243
|
+
lock_key = "#{Platform.abi_key}-#{@layout.full_name(spec)}"
|
|
244
|
+
|
|
245
|
+
promoter.validate_within_root!(@layout.root, assembling_path, label: "assembling")
|
|
246
|
+
promoter.validate_within_root!(@layout.root, cached_dir, label: "cached")
|
|
247
|
+
|
|
248
|
+
result = nil
|
|
249
|
+
promoter.with_staging_dir(prefix: "cached") do |staging|
|
|
250
|
+
FS.clone_tree(assembling_path, staging)
|
|
251
|
+
manifest = build_cached_manifest(spec, staging)
|
|
252
|
+
spec_payload = gemspec ? Marshal.dump(gemspec) : nil
|
|
253
|
+
result = promoter.promote_tree(
|
|
254
|
+
staging_path: staging,
|
|
255
|
+
target_path: cached_dir,
|
|
256
|
+
lock_key: lock_key,
|
|
257
|
+
)
|
|
258
|
+
if result == :promoted
|
|
259
|
+
write_cached_metadata(spec, spec_payload, manifest)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
FileUtils.rm_rf(assembling_path) if Dir.exist?(assembling_path)
|
|
263
|
+
result
|
|
264
|
+
rescue StandardError
|
|
265
|
+
FileUtils.rm_rf(cached_dir) if Dir.exist?(cached_dir)
|
|
266
|
+
raise
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def write_cached_metadata(spec, spec_payload, manifest)
|
|
270
|
+
spec_path = @layout.cached_spec_path(spec)
|
|
271
|
+
manifest_path = @layout.cached_manifest_path(spec)
|
|
272
|
+
FS.mkdir_p(File.dirname(spec_path))
|
|
273
|
+
|
|
274
|
+
FS.atomic_write(spec_path, spec_payload) if spec_payload
|
|
275
|
+
Cache::Manifest.write(manifest_path, manifest)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def build_cached_manifest(spec, cached_dir)
|
|
279
|
+
Cache::Manifest.build(
|
|
280
|
+
spec: spec,
|
|
281
|
+
gem_dir: cached_dir,
|
|
282
|
+
abi_key: Platform.abi_key,
|
|
283
|
+
source: manifest_source_for(spec),
|
|
284
|
+
extensions: ExtensionBuilder.needs_build?(spec, cached_dir),
|
|
285
|
+
)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def manifest_source_for(spec)
|
|
289
|
+
source = spec.source
|
|
290
|
+
if source.is_a?(Source::Git)
|
|
291
|
+
{
|
|
292
|
+
"type" => "git",
|
|
293
|
+
"uri" => source.uri.to_s,
|
|
294
|
+
"revision" => source.revision || source.ref || source.branch || source.tag,
|
|
295
|
+
}.compact
|
|
296
|
+
elsif source.is_a?(Source::Path)
|
|
297
|
+
{
|
|
298
|
+
"type" => "path",
|
|
299
|
+
"path" => File.expand_path(source.path.to_s),
|
|
300
|
+
"uri" => source.path.to_s,
|
|
301
|
+
}
|
|
302
|
+
else
|
|
303
|
+
source_str = source.to_s
|
|
304
|
+
if source_str.start_with?("http://", "https://")
|
|
305
|
+
{ "type" => "rubygems", "uri" => source_str }
|
|
306
|
+
elsif source_str.start_with?("/", ".", "~")
|
|
307
|
+
{ "type" => "path", "path" => File.expand_path(source_str), "uri" => source_str }
|
|
308
|
+
else
|
|
309
|
+
{ "type" => "rubygems", "uri" => source_str }
|
|
180
310
|
end
|
|
181
311
|
end
|
|
182
312
|
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../fs"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
module Scint
|
|
9
|
+
module Installer
|
|
10
|
+
class Promoter
|
|
11
|
+
attr_reader :root, :lock_dir, :staging_dir
|
|
12
|
+
|
|
13
|
+
def initialize(root:, lock_dir: nil, staging_dir: nil)
|
|
14
|
+
@root = File.expand_path(root.to_s)
|
|
15
|
+
@lock_dir = File.expand_path(lock_dir.to_s) if lock_dir
|
|
16
|
+
@staging_dir = File.expand_path(staging_dir.to_s) if staging_dir
|
|
17
|
+
|
|
18
|
+
@lock_dir ||= File.join(@root, "locks", "promotion")
|
|
19
|
+
@staging_dir ||= File.join(@root, "staging")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def with_lock(lock_key)
|
|
23
|
+
lock_path = lock_path_for(lock_key)
|
|
24
|
+
validate_within_root!(@root, lock_path, label: "lock")
|
|
25
|
+
FS.mkdir_p(File.dirname(lock_path))
|
|
26
|
+
|
|
27
|
+
File.open(lock_path, "w") do |file|
|
|
28
|
+
file.flock(File::LOCK_EX)
|
|
29
|
+
yield
|
|
30
|
+
ensure
|
|
31
|
+
file.flock(File::LOCK_UN) rescue nil
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def with_staging_dir(prefix:)
|
|
36
|
+
FS.mkdir_p(@staging_dir)
|
|
37
|
+
staging_path = File.join(@staging_dir, staging_suffix(prefix))
|
|
38
|
+
validate_within_root!(@root, staging_path, label: "staging")
|
|
39
|
+
FileUtils.rm_rf(staging_path) if File.exist?(staging_path)
|
|
40
|
+
|
|
41
|
+
begin
|
|
42
|
+
FS.mkdir_p(staging_path)
|
|
43
|
+
yield staging_path
|
|
44
|
+
ensure
|
|
45
|
+
FileUtils.rm_rf(staging_path) if Dir.exist?(staging_path)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def promote_tree(staging_path:, target_path:, lock_key:)
|
|
50
|
+
validate_within_root!(@root, staging_path, label: "staging")
|
|
51
|
+
validate_within_root!(@root, target_path, label: "target")
|
|
52
|
+
raise CacheError, "Staging path does not exist: #{staging_path}" unless Dir.exist?(staging_path)
|
|
53
|
+
|
|
54
|
+
with_lock(lock_key) do
|
|
55
|
+
if Dir.exist?(target_path)
|
|
56
|
+
FileUtils.rm_rf(staging_path) if Dir.exist?(staging_path)
|
|
57
|
+
return :exists
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
begin
|
|
61
|
+
FS.atomic_move(staging_path, target_path)
|
|
62
|
+
rescue StandardError
|
|
63
|
+
FileUtils.rm_rf(staging_path) if Dir.exist?(staging_path)
|
|
64
|
+
raise
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
:promoted
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def validate_within_root!(root_path, candidate_path, label: "path")
|
|
72
|
+
root_expanded = File.expand_path(root_path.to_s)
|
|
73
|
+
candidate_expanded = File.expand_path(candidate_path.to_s)
|
|
74
|
+
within_root = candidate_expanded == root_expanded ||
|
|
75
|
+
candidate_expanded.start_with?("#{root_expanded}/")
|
|
76
|
+
return if within_root
|
|
77
|
+
|
|
78
|
+
raise CacheError, "#{label.capitalize} escapes cache root: #{candidate_path}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def lock_path_for(lock_key)
|
|
82
|
+
safe_key = sanitize_key(lock_key)
|
|
83
|
+
File.join(@lock_dir, "#{safe_key}.lock")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def sanitize_key(key)
|
|
87
|
+
key.to_s.gsub(/[^0-9A-Za-z._-]/, "_")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def staging_suffix(prefix)
|
|
91
|
+
safe = sanitize_key(prefix)
|
|
92
|
+
token = SecureRandom.hex(6)
|
|
93
|
+
"#{safe}.#{Process.pid}.#{Thread.current.object_id}.#{token}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/scint/linker.sh
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# scint-linker — bulk-hardlink cached gems into a bundle.
|
|
3
|
+
#
|
|
4
|
+
# Protocol (stdin):
|
|
5
|
+
# Line 1: source parent dir (e.g. ~/.cache/scint/cached/ruby-3.4.7-x86_64-linux)
|
|
6
|
+
# Line 2: dest parent dir (e.g. .bundle/ruby/3.4.0/gems)
|
|
7
|
+
# Remaining lines: gem directory basenames (e.g. "rack-3.2.4")
|
|
8
|
+
#
|
|
9
|
+
# The script probes the best copy strategy once (cpio, hardlink, reflink,
|
|
10
|
+
# copy) and uses it for every gem. When cpio is available it reads
|
|
11
|
+
# .scint-files from each cached gem so only listed files are materialized.
|
|
12
|
+
# Otherwise falls back to cp -al / cp --reflink / cp -R.
|
|
13
|
+
|
|
14
|
+
set -euo pipefail
|
|
15
|
+
|
|
16
|
+
read -r SRC
|
|
17
|
+
read -r DST
|
|
18
|
+
mkdir -p "$DST"
|
|
19
|
+
|
|
20
|
+
# ── detect strategy ──────────────────────────────────────────────
|
|
21
|
+
STRATEGY=""
|
|
22
|
+
|
|
23
|
+
# Probe needs a real file to test against. Find one quickly.
|
|
24
|
+
probe_src=""
|
|
25
|
+
for candidate in "$SRC"/*/.scint-files; do
|
|
26
|
+
[ -f "$candidate" ] && probe_src="$candidate" && break
|
|
27
|
+
done
|
|
28
|
+
|
|
29
|
+
if [ -z "$probe_src" ]; then
|
|
30
|
+
# No .scint-files at all — fall back to cp -al or cp -R
|
|
31
|
+
if cp -al --version >/dev/null 2>&1; then
|
|
32
|
+
STRATEGY="cp-al"
|
|
33
|
+
else
|
|
34
|
+
STRATEGY="cp"
|
|
35
|
+
fi
|
|
36
|
+
else
|
|
37
|
+
probe_dst="$DST/.scint-probe-$$"
|
|
38
|
+
|
|
39
|
+
# 1. cpio -pld (file-list driven hardlinks — ideal)
|
|
40
|
+
if command -v cpio >/dev/null 2>&1; then
|
|
41
|
+
if echo "$probe_src" | cpio -pld "$DST" >/dev/null 2>&1; then
|
|
42
|
+
rm -f "$probe_dst" 2>/dev/null
|
|
43
|
+
STRATEGY="cpio"
|
|
44
|
+
fi
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
# 2. hardlink via ln
|
|
48
|
+
if [ -z "$STRATEGY" ]; then
|
|
49
|
+
if ln "$probe_src" "$probe_dst" 2>/dev/null; then
|
|
50
|
+
rm -f "$probe_dst"
|
|
51
|
+
STRATEGY="cp-al"
|
|
52
|
+
fi
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
# 3. reflink (btrfs/xfs/APFS)
|
|
56
|
+
if [ -z "$STRATEGY" ]; then
|
|
57
|
+
if [ "$(uname)" = "Darwin" ]; then
|
|
58
|
+
if cp -c "$probe_src" "$probe_dst" 2>/dev/null; then
|
|
59
|
+
rm -f "$probe_dst"
|
|
60
|
+
STRATEGY="reflink"
|
|
61
|
+
fi
|
|
62
|
+
else
|
|
63
|
+
if cp --reflink=always "$probe_src" "$probe_dst" 2>/dev/null; then
|
|
64
|
+
rm -f "$probe_dst"
|
|
65
|
+
STRATEGY="reflink"
|
|
66
|
+
fi
|
|
67
|
+
fi
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
rm -f "$probe_dst" 2>/dev/null
|
|
71
|
+
[ -z "$STRATEGY" ] && STRATEGY="cp"
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
# ── link gems ────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
link_cpio() {
|
|
77
|
+
# Read .scint-files, prefix each line with gem name, pipe to cpio -pld.
|
|
78
|
+
# One cpio call per gem (cpio needs a single source root).
|
|
79
|
+
local gem="$1"
|
|
80
|
+
local dotfiles="$SRC/$gem/.scint-files"
|
|
81
|
+
if [ -f "$dotfiles" ]; then
|
|
82
|
+
(cd "$SRC/$gem" && cpio -pld "$DST/$gem" < "$dotfiles" 2>/dev/null)
|
|
83
|
+
else
|
|
84
|
+
cp -al "$SRC/$gem" "$DST/$gem"
|
|
85
|
+
fi
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# For cp-al: batch all gems into one call
|
|
89
|
+
BATCH=()
|
|
90
|
+
|
|
91
|
+
flush_batch() {
|
|
92
|
+
[ ${#BATCH[@]} -eq 0 ] && return
|
|
93
|
+
case "$STRATEGY" in
|
|
94
|
+
cp-al)
|
|
95
|
+
cp -al "${BATCH[@]}" "$DST/" 2>/dev/null || {
|
|
96
|
+
# Individual fallback on batch failure
|
|
97
|
+
for s in "${BATCH[@]}"; do
|
|
98
|
+
cp -al "$s" "$DST/" 2>/dev/null || cp -R "$s" "$DST/"
|
|
99
|
+
done
|
|
100
|
+
}
|
|
101
|
+
;;
|
|
102
|
+
reflink)
|
|
103
|
+
if [ "$(uname)" = "Darwin" ]; then
|
|
104
|
+
cp -cR "${BATCH[@]}" "$DST/" 2>/dev/null || {
|
|
105
|
+
for s in "${BATCH[@]}"; do cp -R "$s" "$DST/"; done
|
|
106
|
+
}
|
|
107
|
+
else
|
|
108
|
+
cp --reflink=always -R "${BATCH[@]}" "$DST/" 2>/dev/null || {
|
|
109
|
+
for s in "${BATCH[@]}"; do cp -R "$s" "$DST/"; done
|
|
110
|
+
}
|
|
111
|
+
fi
|
|
112
|
+
;;
|
|
113
|
+
cp)
|
|
114
|
+
cp -R "${BATCH[@]}" "$DST/"
|
|
115
|
+
;;
|
|
116
|
+
esac
|
|
117
|
+
BATCH=()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
while IFS= read -r gem; do
|
|
121
|
+
[ -z "$gem" ] && continue
|
|
122
|
+
[ -d "$SRC/$gem" ] || continue
|
|
123
|
+
[ -d "$DST/$gem" ] && continue
|
|
124
|
+
|
|
125
|
+
case "$STRATEGY" in
|
|
126
|
+
cpio)
|
|
127
|
+
link_cpio "$gem"
|
|
128
|
+
;;
|
|
129
|
+
*)
|
|
130
|
+
BATCH+=("$SRC/$gem")
|
|
131
|
+
# Flush in chunks to stay under ARG_MAX
|
|
132
|
+
[ ${#BATCH[@]} -ge 200 ] && flush_batch
|
|
133
|
+
;;
|
|
134
|
+
esac
|
|
135
|
+
done
|
|
136
|
+
|
|
137
|
+
flush_batch
|