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.
@@ -4,8 +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"
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"
9
17
 
10
18
  module Scint
11
19
  module Installer
@@ -44,21 +52,30 @@ module Scint
44
52
  already_cached = []
45
53
 
46
54
  sorted.each do |entry|
47
- inbound = @layout.inbound_path(entry.spec)
48
- extracted = @layout.extracted_path(entry.spec)
55
+ spec = entry.spec
56
+ inbound = @layout.inbound_path(spec)
57
+ assembling = @layout.assembling_path(spec)
58
+ cached = @layout.cached_path(spec)
49
59
 
50
- if File.directory?(extracted)
51
- # Already extracted -- load gemspec from cache or re-read
52
- 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)
53
62
  already_cached << PreparedGem.new(
54
- spec: entry.spec,
55
- extracted_path: extracted,
63
+ spec: spec,
64
+ extracted_path: cached,
56
65
  gemspec: gemspec,
57
66
  from_cache: true,
58
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
+ )
59
76
  elsif File.exist?(inbound)
60
77
  # Downloaded but not extracted
61
- already_cached << extract_gem(entry.spec, inbound)
78
+ already_cached << extract_gem(spec, inbound)
62
79
  else
63
80
  # Need to download
64
81
  to_download << entry
@@ -104,65 +121,98 @@ module Scint
104
121
 
105
122
  # Prepare a single entry (for use with scheduler).
106
123
  def prepare_one(entry)
107
- inbound = @layout.inbound_path(entry.spec)
108
- extracted = @layout.extracted_path(entry.spec)
124
+ spec = entry.spec
125
+ inbound = @layout.inbound_path(spec)
126
+ assembling = @layout.assembling_path(spec)
127
+ cached = @layout.cached_path(spec)
109
128
 
110
- if File.directory?(extracted)
111
- gemspec = load_cached_spec(entry.spec) || read_gemspec_from_extracted(extracted, entry.spec)
129
+ if Cache::Validity.cached_valid?(spec, @layout)
130
+ gemspec = load_cached_spec(spec) || read_gemspec_from_extracted(cached, spec)
112
131
  return PreparedGem.new(
113
- spec: entry.spec,
114
- extracted_path: extracted,
132
+ spec: spec,
133
+ extracted_path: cached,
115
134
  gemspec: gemspec,
116
135
  from_cache: true,
117
136
  )
118
137
  end
119
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
+
120
150
  unless File.exist?(inbound)
121
151
  uri = gem_download_uri(entry)
122
152
  @download_pool.download(uri, inbound)
123
153
  end
124
154
 
125
- extract_gem(entry.spec, inbound)
155
+ extract_gem(spec, inbound)
126
156
  end
127
157
 
128
158
  private
129
159
 
130
160
  def extract_gem(spec, gem_path)
131
- dest = @layout.extracted_path(spec)
161
+ cached = @layout.cached_path(spec)
162
+ assembling = @layout.assembling_path(spec)
132
163
 
133
- if File.directory?(dest)
134
- gemspec = load_cached_spec(spec) || read_gemspec_from_extracted(dest, spec)
135
- return PreparedGem.new(spec: spec, extracted_path: dest, gemspec: gemspec, from_cache: true)
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)
136
167
  end
137
168
 
138
- # Extract to temp dir, then atomic move
139
- tmp_dest = "#{dest}.#{Process.pid}.tmp"
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)
172
+ end
173
+
174
+ # Extract to temp dir in assembling, then atomic move
175
+ tmp_dest = "#{assembling}.#{Process.pid}.tmp"
140
176
  FileUtils.rm_rf(tmp_dest) if File.exist?(tmp_dest)
141
177
 
142
178
  result = @package.extract(gem_path, tmp_dest)
143
- FS.atomic_move(tmp_dest, dest)
144
-
145
- # Cache the gemspec as Marshal for fast future loads
146
- cache_spec(spec, result[:gemspec])
147
-
148
- PreparedGem.new(
149
- spec: spec,
150
- extracted_path: dest,
151
- gemspec: result[:gemspec],
152
- from_cache: false,
153
- )
179
+ FS.atomic_move(tmp_dest, assembling)
180
+
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
154
197
  end
155
198
 
156
199
  def load_cached_spec(spec)
157
- path = @layout.spec_cache_path(spec)
200
+ path = @layout.cached_spec_path(spec)
158
201
  return nil unless File.exist?(path)
159
- Marshal.load(File.binread(path))
160
- rescue ArgumentError, TypeError, EOFError
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
161
211
  nil
162
212
  end
163
213
 
164
214
  def cache_spec(spec, gemspec)
165
- path = @layout.spec_cache_path(spec)
215
+ path = @layout.cached_spec_path(spec)
166
216
  FS.atomic_write(path, Marshal.dump(gemspec))
167
217
  rescue StandardError
168
218
  # Non-fatal: cache miss on next load
@@ -172,25 +222,98 @@ module Scint
172
222
  pattern = File.join(extracted_dir, "*.gemspec")
173
223
  candidates = Dir.glob(pattern)
174
224
  if candidates.any?
225
+ version = spec.respond_to?(:version) ? spec.version.to_s : nil
226
+ old_version = ENV["VERSION"]
175
227
  begin
176
- ::Gem::Specification.load(candidates.first)
177
- rescue StandardError
228
+ ENV["VERSION"] = version if version && !ENV["VERSION"]
229
+ SpecUtils.load_gemspec(candidates.first, isolate: true)
230
+ rescue SystemExit, StandardError
178
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 }
179
310
  end
180
311
  end
181
312
  end
182
313
 
183
314
  def gem_download_uri(entry)
184
315
  spec = entry.spec
185
- name = spec.respond_to?(:name) ? spec.name : spec[:name]
186
- version = spec.respond_to?(:version) ? spec.version : spec[:version]
187
- platform = spec.respond_to?(:platform) ? spec.platform : spec[:platform]
188
-
189
- filename = if platform && platform.to_s != "ruby" && platform.to_s != ""
190
- "#{name}-#{version}-#{platform}.gem"
191
- else
192
- "#{name}-#{version}.gem"
193
- end
316
+ filename = "#{SpecUtils.full_name(spec)}.gem"
194
317
 
195
318
  # Use cached_path if provided, otherwise construct from source
196
319
  if entry.cached_path
@@ -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
@@ -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
@@ -3,6 +3,7 @@
3
3
  require_relative "../source/rubygems"
4
4
  require_relative "../source/git"
5
5
  require_relative "../source/path"
6
+ require_relative "../spec_utils"
6
7
 
7
8
  module Scint
8
9
  module Lockfile
@@ -209,7 +210,7 @@ module Scint
209
210
  platform = $4 || "ruby"
210
211
  checksums_str = $6
211
212
 
212
- key = platform == "ruby" ? "#{name}-#{version}" : "#{name}-#{version}-#{platform}"
213
+ key = SpecUtils.full_name_for(name, version, platform)
213
214
 
214
215
  if checksums_str
215
216
  @checksums[key] = checksums_str.split(",").map(&:strip)