scint 0.7.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.
@@ -1,13 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "open3"
4
5
  require_relative "layout"
6
+ require_relative "manifest"
7
+ require_relative "validity"
5
8
  require_relative "../errors"
6
9
  require_relative "../downloader/pool"
7
10
  require_relative "../gem/package"
8
11
  require_relative "../fs"
9
12
  require_relative "../platform"
10
13
  require_relative "../worker_pool"
14
+ require_relative "../installer/extension_builder"
15
+ require_relative "../installer/promoter"
16
+ require_relative "../source/git"
17
+ require_relative "../source/path"
11
18
 
12
19
  module Scint
13
20
  module Cache
@@ -23,6 +30,8 @@ module Scint
23
30
  @downloader_factory = downloader_factory || lambda { |size, creds|
24
31
  Downloader::Pool.new(size: size, credentials: creds)
25
32
  }
33
+ @git_mutexes = {}
34
+ @git_mutexes_lock = Thread::Mutex.new
26
35
  end
27
36
 
28
37
  # Returns summary hash:
@@ -32,65 +41,99 @@ module Scint
32
41
  ignored = 0
33
42
  skipped = 0
34
43
 
35
- tasks = []
44
+ gem_tasks = []
45
+ git_tasks = []
46
+
36
47
  specs.each do |spec|
37
- if prewarmable?(spec)
38
- tasks << task_for(spec)
48
+ if git_source?(spec.source)
49
+ git_tasks << task_for_git(spec)
50
+ elsif rubygems_source?(spec.source)
51
+ gem_tasks << task_for(spec)
39
52
  else
40
53
  ignored += 1
41
54
  end
42
55
  end
43
56
 
44
- tasks.each do |task|
45
- next unless @force
57
+ all_tasks = gem_tasks + git_tasks
46
58
 
59
+ all_tasks.each do |task|
60
+ next unless @force
47
61
  purge_artifacts(task.spec)
48
62
  task.download = true
49
63
  task.extract = true
50
64
  end
51
65
 
52
- tasks.each do |task|
53
- if !task.download && !task.extract
54
- skipped += 1
55
- end
66
+ all_tasks.each do |task|
67
+ skipped += 1 if !task.download && !task.extract
56
68
  end
57
69
 
58
- work_tasks = tasks.select { |task| task.download || task.extract }
59
- return result_hash(work_tasks.size, skipped, ignored, failures) if work_tasks.empty?
70
+ work_gem_tasks = gem_tasks.select { |t| t.download || t.extract }
71
+ work_git_tasks = git_tasks.select { |t| t.download || t.extract }
60
72
 
61
- download_errors = download_tasks(work_tasks.select(&:download))
73
+ return result_hash(0, skipped, ignored, failures) if work_gem_tasks.empty? && work_git_tasks.empty?
74
+
75
+ # Phase 1: Fetch — download .gem files + clone/fetch git repos
76
+ download_errors = download_tasks(work_gem_tasks.select(&:download))
62
77
  failures.concat(download_errors)
63
78
 
64
- remaining = work_tasks.reject do |task|
65
- failures.any? { |f| f[:spec] == task.spec }
66
- end
79
+ git_fetch_errors = git_fetch_tasks(work_git_tasks.select(&:download))
80
+ failures.concat(git_fetch_errors)
67
81
 
68
- extract_errors = extract_tasks(remaining.select(&:extract))
82
+ # Phase 2: Extract — expand .gem payloads + checkout/assemble git trees
83
+ remaining_gems = work_gem_tasks.reject { |t| failures.any? { |f| f[:spec] == t.spec } }
84
+ extract_errors = extract_tasks(remaining_gems.select(&:extract))
69
85
  failures.concat(extract_errors)
70
86
 
71
- result_hash(work_tasks.size - failures.size, skipped, ignored, failures)
87
+ remaining_gits = work_git_tasks.reject { |t| failures.any? { |f| f[:spec] == t.spec } }
88
+ git_assemble_errors = git_assemble_tasks(remaining_gits.select(&:extract))
89
+ failures.concat(git_assemble_errors)
90
+
91
+ total_work = work_gem_tasks.size + work_git_tasks.size
92
+ result_hash(total_work - failures.size, skipped, ignored, failures)
72
93
  end
73
94
 
74
95
  private
75
96
 
97
+ # -- Task classification -------------------------------------------------
98
+
99
+ def git_source?(source)
100
+ source.is_a?(Source::Git)
101
+ end
102
+
103
+ def rubygems_source?(source)
104
+ source_str = source.to_s
105
+ source_str.start_with?("http://", "https://")
106
+ end
107
+
76
108
  def task_for(spec)
77
109
  inbound = @cache.inbound_path(spec)
78
- extracted = @cache.extracted_path(spec)
79
- metadata = @cache.spec_cache_path(spec)
110
+ cached_valid = Cache::Validity.cached_valid?(spec, @cache)
80
111
 
81
112
  Task.new(
82
113
  spec: spec,
83
114
  download: !File.exist?(inbound),
84
- extract: !Dir.exist?(extracted) || !File.exist?(metadata),
115
+ extract: !cached_valid,
85
116
  )
86
117
  end
87
118
 
88
- def prewarmable?(spec)
89
- source = spec.source
90
- source_str = source.to_s
91
- source_str.start_with?("http://", "https://")
119
+ def task_for_git(spec)
120
+ cached_valid = Cache::Validity.cached_valid?(spec, @cache)
121
+ return Task.new(spec: spec, download: false, extract: false) if cached_valid
122
+
123
+ uri = spec.source.uri.to_s
124
+ bare_repo = @cache.git_path(uri)
125
+
126
+ Task.new(
127
+ spec: spec,
128
+ download: !Dir.exist?(bare_repo),
129
+ # Git gems always need assembly if not cached, even when the bare
130
+ # repo is already present (the checkout may be stale/missing).
131
+ extract: true,
132
+ )
92
133
  end
93
134
 
135
+ # -- Rubygems download ---------------------------------------------------
136
+
94
137
  def download_tasks(tasks)
95
138
  return [] if tasks.empty?
96
139
 
@@ -114,6 +157,57 @@ module Scint
114
157
  downloader&.close
115
158
  end
116
159
 
160
+ # -- Git fetch (clone/fetch bare repos) ----------------------------------
161
+
162
+ def git_fetch_tasks(tasks)
163
+ return [] if tasks.empty?
164
+
165
+ failures = []
166
+ mutex = Thread::Mutex.new
167
+ done = Thread::Queue.new
168
+
169
+ # Deduplicate by URI so we only clone/fetch each repo once.
170
+ by_uri = {}
171
+ tasks.each do |task|
172
+ uri = task.spec.source.uri.to_s
173
+ by_uri[uri] ||= []
174
+ by_uri[uri] << task
175
+ end
176
+
177
+ pool = WorkerPool.new([@jobs, by_uri.size].min, name: "prewarm-git-fetch")
178
+ pool.start do |uri|
179
+ bare_repo = @cache.git_path(uri)
180
+ git_mutex_for(bare_repo).synchronize do
181
+ if Dir.exist?(bare_repo)
182
+ fetch_git_repo(bare_repo)
183
+ else
184
+ clone_git_repo(uri, bare_repo)
185
+ end
186
+ end
187
+ true
188
+ end
189
+
190
+ by_uri.each do |uri, uri_tasks|
191
+ pool.enqueue(uri) do |job|
192
+ mutex.synchronize do
193
+ if job[:state] == :failed
194
+ uri_tasks.each do |task|
195
+ failures << { spec: task.spec, error: job[:error] }
196
+ end
197
+ end
198
+ end
199
+ done.push(true)
200
+ end
201
+ end
202
+
203
+ by_uri.size.times { done.pop }
204
+ pool.stop
205
+
206
+ failures
207
+ end
208
+
209
+ # -- Rubygems extract ----------------------------------------------------
210
+
117
211
  def extract_tasks(tasks)
118
212
  return [] if tasks.empty?
119
213
 
@@ -130,23 +224,58 @@ module Scint
130
224
  raise CacheError, "Missing downloaded gem for #{spec.name}: #{inbound}"
131
225
  end
132
226
 
133
- extracted = @cache.extracted_path(spec)
134
- metadata = @cache.spec_cache_path(spec)
227
+ assembling = @cache.assembling_path(spec)
228
+ tmp = "#{assembling}.#{Process.pid}.#{Thread.current.object_id}.tmp"
135
229
 
136
230
  gemspec = nil
137
231
  if task.extract
138
- FileUtils.rm_rf(extracted)
139
- FS.mkdir_p(extracted)
140
- result = GemPkg::Package.new.extract(inbound, extracted)
232
+ FileUtils.rm_rf(assembling)
233
+ FileUtils.rm_rf(tmp)
234
+ FS.mkdir_p(File.dirname(assembling))
235
+
236
+ result = GemPkg::Package.new.extract(inbound, tmp)
141
237
  gemspec = result[:gemspec]
142
- elsif !File.exist?(metadata)
143
- gemspec = GemPkg::Package.new.read_metadata(inbound)
238
+ FS.atomic_move(tmp, assembling)
144
239
  end
145
240
 
146
241
  if gemspec
147
- FS.atomic_write(metadata, gemspec.to_yaml)
242
+ promote_assembled(spec, assembling, gemspec)
243
+ end
244
+
245
+ true
246
+ ensure
247
+ FileUtils.rm_rf(tmp) if tmp && File.exist?(tmp)
248
+ end
249
+
250
+ tasks.each do |task|
251
+ pool.enqueue(task) do |job|
252
+ mutex.synchronize do
253
+ if job[:state] == :failed
254
+ failures << { spec: job[:payload].spec, error: job[:error] }
255
+ end
256
+ end
257
+ done.push(true)
148
258
  end
259
+ end
149
260
 
261
+ tasks.size.times { done.pop }
262
+ pool.stop
263
+
264
+ failures
265
+ end
266
+
267
+ # -- Git assemble (checkout + promote) -----------------------------------
268
+
269
+ def git_assemble_tasks(tasks)
270
+ return [] if tasks.empty?
271
+
272
+ failures = []
273
+ mutex = Thread::Mutex.new
274
+ done = Thread::Queue.new
275
+
276
+ pool = WorkerPool.new(@jobs, name: "prewarm-git-assemble")
277
+ pool.start do |task|
278
+ assemble_git_spec(task.spec)
150
279
  true
151
280
  end
152
281
 
@@ -167,6 +296,279 @@ module Scint
167
296
  failures
168
297
  end
169
298
 
299
+ # Checkout a git source into the assembling cache and promote to cached.
300
+ # This mirrors CLI::Install#assemble_git_spec but without install/link.
301
+ def assemble_git_spec(spec)
302
+ return if Cache::Validity.cached_valid?(spec, @cache)
303
+
304
+ source = spec.source
305
+ uri = source.uri.to_s
306
+ revision = source.revision || source.ref || source.branch || source.tag || "HEAD"
307
+ submodules = source.respond_to?(:submodules) && !!source.submodules
308
+
309
+ bare_repo = @cache.git_path(uri)
310
+ raise CacheError, "Missing git repo for #{spec.name}: #{bare_repo}" unless Dir.exist?(bare_repo)
311
+
312
+ git_mutex_for(bare_repo).synchronize do
313
+ tmp_checkout = nil
314
+ tmp_assembled = nil
315
+
316
+ begin
317
+ resolved_revision = resolve_git_revision(bare_repo, revision)
318
+ assembling = @cache.assembling_path(spec)
319
+ tmp_checkout = "#{assembling}.checkout.#{Process.pid}.#{Thread.current.object_id}.tmp"
320
+ tmp_assembled = "#{assembling}.#{Process.pid}.#{Thread.current.object_id}.tmp"
321
+
322
+ FileUtils.rm_rf(assembling)
323
+ FileUtils.rm_rf(tmp_checkout)
324
+ FileUtils.rm_rf(tmp_assembled)
325
+ FS.mkdir_p(File.dirname(assembling))
326
+
327
+ if submodules
328
+ checkout_git_tree_with_submodules(bare_repo, tmp_checkout, resolved_revision, spec, uri)
329
+ else
330
+ checkout_git_tree(bare_repo, tmp_checkout, resolved_revision, spec, uri)
331
+ end
332
+
333
+ # Strip .git internals for deterministic cache content
334
+ Dir.glob(File.join(tmp_checkout, "**", ".git"), File::FNM_DOTMATCH).each do |path|
335
+ FileUtils.rm_rf(path)
336
+ end
337
+
338
+ gem_root = resolve_git_gem_subdir(tmp_checkout, spec)
339
+ gem_rel = git_relative_root(tmp_checkout, gem_root)
340
+ dest_path = gem_rel.empty? ? tmp_assembled : File.join(tmp_assembled, gem_rel)
341
+
342
+ FS.clone_tree(gem_root, dest_path)
343
+ copy_gemspec_root_files(tmp_checkout, gem_root, tmp_assembled, spec)
344
+ FS.atomic_move(tmp_assembled, assembling)
345
+
346
+ gem_subdir = begin
347
+ resolve_git_gem_subdir(assembling, spec)
348
+ rescue InstallError
349
+ assembling
350
+ end
351
+ gemspec = read_gemspec_from_dir(gem_subdir, spec)
352
+
353
+ unless Installer::ExtensionBuilder.needs_build?(spec, assembling)
354
+ promote_assembled(spec, assembling, gemspec)
355
+ end
356
+ ensure
357
+ FileUtils.rm_rf(tmp_checkout) if tmp_checkout && File.exist?(tmp_checkout)
358
+ FileUtils.rm_rf(tmp_assembled) if tmp_assembled && File.exist?(tmp_assembled)
359
+ end
360
+ end
361
+ end
362
+
363
+ # -- Promote / metadata --------------------------------------------------
364
+
365
+ def promote_assembled(spec, assembling, gemspec)
366
+ return unless assembling && Dir.exist?(assembling)
367
+
368
+ cached_dir = @cache.cached_path(spec)
369
+ promoter = Installer::Promoter.new(root: @cache.root)
370
+ lock_key = "#{Platform.abi_key}-#{@cache.full_name(spec)}"
371
+ extensions = Installer::ExtensionBuilder.needs_build?(spec, assembling)
372
+
373
+ promoter.with_staging_dir(prefix: "cached") do |staging|
374
+ FS.clone_tree(assembling, staging)
375
+ manifest = Cache::Manifest.build(
376
+ spec: spec,
377
+ gem_dir: staging,
378
+ abi_key: Platform.abi_key,
379
+ source: manifest_source_for(spec),
380
+ extensions: extensions,
381
+ )
382
+ spec_payload = gemspec ? Marshal.dump(gemspec) : nil
383
+ result = promoter.promote_tree(
384
+ staging_path: staging,
385
+ target_path: cached_dir,
386
+ lock_key: lock_key,
387
+ )
388
+ write_cached_metadata(spec, spec_payload, manifest) if result == :promoted
389
+ end
390
+
391
+ FileUtils.rm_rf(assembling)
392
+ end
393
+
394
+ def manifest_source_for(spec)
395
+ source = spec.source
396
+ if source.is_a?(Source::Git)
397
+ {
398
+ "type" => "git",
399
+ "uri" => source.uri.to_s,
400
+ "revision" => source.revision || source.ref || source.branch || source.tag,
401
+ }.compact
402
+ else
403
+ { "type" => "rubygems", "uri" => source.to_s }
404
+ end
405
+ end
406
+
407
+ def write_cached_metadata(spec, spec_payload, manifest)
408
+ spec_path = @cache.cached_spec_path(spec)
409
+ manifest_path = @cache.cached_manifest_path(spec)
410
+ FS.mkdir_p(File.dirname(spec_path))
411
+
412
+ FS.atomic_write(spec_path, spec_payload) if spec_payload
413
+ Cache::Manifest.write(manifest_path, manifest)
414
+ end
415
+
416
+ # -- Git helpers (same logic as CLI::Install) ----------------------------
417
+
418
+ def clone_git_repo(uri, bare_repo)
419
+ FS.mkdir_p(File.dirname(bare_repo))
420
+ _out, err, status = git_capture3("clone", "--bare", uri.to_s, bare_repo)
421
+ unless status.success?
422
+ raise CacheError, "Git clone failed for #{uri}: #{err.to_s.strip}"
423
+ end
424
+ end
425
+
426
+ def fetch_git_repo(bare_repo)
427
+ _out, err, status = git_capture3(
428
+ "--git-dir", bare_repo,
429
+ "fetch", "--prune", "origin",
430
+ "+refs/heads/*:refs/heads/*",
431
+ "+refs/tags/*:refs/tags/*",
432
+ )
433
+ unless status.success?
434
+ raise CacheError, "Git fetch failed for #{bare_repo}: #{err.to_s.strip}"
435
+ end
436
+ end
437
+
438
+ def resolve_git_revision(bare_repo, revision)
439
+ out, err, status = git_capture3("--git-dir", bare_repo, "rev-parse", "#{revision}^{commit}")
440
+ unless status.success?
441
+ raise CacheError, "Unable to resolve git revision #{revision.inspect} in #{bare_repo}: #{err.to_s.strip}"
442
+ end
443
+ out.strip
444
+ end
445
+
446
+ def checkout_git_tree(bare_repo, destination, resolved_revision, spec, uri)
447
+ FileUtils.mkdir_p(destination)
448
+ _out, err, status = git_capture3(
449
+ "--git-dir", bare_repo,
450
+ "--work-tree", destination,
451
+ "checkout", "-f", resolved_revision, "--", ".",
452
+ )
453
+ unless status.success?
454
+ raise CacheError, "Git checkout failed for #{spec.name} (#{uri}@#{resolved_revision}): #{err.to_s.strip}"
455
+ end
456
+ end
457
+
458
+ def checkout_git_tree_with_submodules(bare_repo, destination, resolved_revision, spec, uri)
459
+ worktree = "#{destination}.worktree"
460
+ FileUtils.rm_rf(worktree)
461
+
462
+ _out, err, status = git_capture3(
463
+ "--git-dir", bare_repo,
464
+ "worktree", "add", "--detach", "--force", worktree, resolved_revision,
465
+ )
466
+ unless status.success?
467
+ raise CacheError, "Git worktree checkout failed for #{spec.name} (#{uri}@#{resolved_revision}): #{err.to_s.strip}"
468
+ end
469
+
470
+ begin
471
+ _sub_out, sub_err, sub_status = git_capture3(
472
+ "-C", worktree,
473
+ "-c", "protocol.file.allow=always",
474
+ "submodule", "update", "--init", "--recursive",
475
+ )
476
+ unless sub_status.success?
477
+ raise CacheError, "Git submodule update failed for #{spec.name} (#{uri}@#{resolved_revision}): #{sub_err.to_s.strip}"
478
+ end
479
+
480
+ FS.clone_tree(worktree, destination)
481
+
482
+ Dir.glob(File.join(destination, "**", ".git"), File::FNM_DOTMATCH).each do |path|
483
+ FileUtils.rm_rf(path)
484
+ end
485
+ ensure
486
+ git_capture3("--git-dir", bare_repo, "worktree", "remove", "--force", worktree)
487
+ FileUtils.rm_rf(worktree)
488
+ end
489
+ end
490
+
491
+ def resolve_git_gem_subdir(repo_root, spec)
492
+ name = spec.name
493
+ return repo_root if File.exist?(File.join(repo_root, "#{name}.gemspec"))
494
+
495
+ source = spec.source
496
+ glob = source.respond_to?(:glob) ? source.glob : Source::Git::DEFAULT_GLOB
497
+ Dir.glob(File.join(repo_root, glob)).each do |path|
498
+ return File.dirname(path) if File.basename(path, ".gemspec") == name
499
+ end
500
+ Dir.glob(File.join(repo_root, "**", "*.gemspec")).each do |path|
501
+ return File.dirname(path) if File.basename(path, ".gemspec") == name
502
+ end
503
+
504
+ raise CacheError,
505
+ "Git source #{source.uri} does not contain #{name}.gemspec (glob: #{glob.inspect})"
506
+ end
507
+
508
+ def git_relative_root(repo_root, gem_root)
509
+ repo_root = File.expand_path(repo_root.to_s)
510
+ gem_root = File.expand_path(gem_root.to_s)
511
+ return "" if repo_root == gem_root
512
+
513
+ if gem_root.start_with?("#{repo_root}/")
514
+ return gem_root.delete_prefix("#{repo_root}/")
515
+ end
516
+
517
+ File.basename(gem_root)
518
+ end
519
+
520
+ def copy_gemspec_root_files(repo_root, gem_root, dest_root, spec)
521
+ repo_root = File.expand_path(repo_root.to_s)
522
+ gem_root = File.expand_path(gem_root.to_s)
523
+ return if repo_root == gem_root
524
+
525
+ gemspec_path = Dir.glob(File.join(gem_root, "*.gemspec")).first
526
+ gemspec_path ||= File.join(gem_root, "#{spec.name}.gemspec")
527
+ return unless File.exist?(gemspec_path)
528
+
529
+ content = File.read(gemspec_path) rescue nil
530
+ return unless content
531
+
532
+ %w[RAILS_VERSION VERSION].each do |file|
533
+ next unless content.include?(file)
534
+ source = File.join(repo_root, file)
535
+ next unless File.file?(source)
536
+ dest = File.join(dest_root, file)
537
+ next if File.exist?(dest)
538
+ FS.clonefile(source, dest)
539
+ end
540
+ end
541
+
542
+ def read_gemspec_from_dir(dir, spec)
543
+ return nil unless dir && Dir.exist?(dir)
544
+
545
+ candidates = Dir.glob(File.join(dir, "*.gemspec"))
546
+ return nil if candidates.empty?
547
+
548
+ version = spec.respond_to?(:version) ? spec.version.to_s : nil
549
+ old_version = ENV["VERSION"]
550
+ begin
551
+ ENV["VERSION"] = version if version && !ENV["VERSION"]
552
+ Gem::Specification.load(candidates.first)
553
+ rescue SystemExit, StandardError
554
+ nil
555
+ ensure
556
+ ENV["VERSION"] = old_version
557
+ end
558
+ end
559
+
560
+ def git_mutex_for(repo_path)
561
+ @git_mutexes_lock.synchronize do
562
+ @git_mutexes[repo_path] ||= Thread::Mutex.new
563
+ end
564
+ end
565
+
566
+ def git_capture3(*args)
567
+ Open3.capture3("git", "-c", "core.fsmonitor=false", *args)
568
+ end
569
+
570
+ # -- Rubygems helpers ----------------------------------------------------
571
+
170
572
  def download_uri_for(spec)
171
573
  source = spec.source.to_s.chomp("/")
172
574
  "#{source}/gems/#{@cache.full_name(spec)}.gem"
@@ -174,8 +576,13 @@ module Scint
174
576
 
175
577
  def purge_artifacts(spec)
176
578
  FileUtils.rm_f(@cache.inbound_path(spec))
177
- FileUtils.rm_rf(@cache.extracted_path(spec))
579
+ FileUtils.rm_rf(@cache.assembling_path(spec))
580
+ FileUtils.rm_rf(@cache.cached_path(spec))
581
+ FileUtils.rm_f(@cache.cached_spec_path(spec))
582
+ FileUtils.rm_f(@cache.cached_manifest_path(spec))
178
583
  FileUtils.rm_f(@cache.spec_cache_path(spec))
584
+ FileUtils.rm_rf(@cache.extracted_path(spec))
585
+ FileUtils.rm_rf(@cache.ext_path(spec))
179
586
  end
180
587
 
181
588
  def result_hash(warmed, skipped, ignored, failures)
@@ -187,6 +594,11 @@ module Scint
187
594
  failures: failures,
188
595
  }
189
596
  end
597
+
598
+ # Legacy compatibility — old callers may still check this.
599
+ def prewarmable?(spec)
600
+ rubygems_source?(spec.source) || git_source?(spec.source)
601
+ end
190
602
  end
191
603
  end
192
604
  end