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.
@@ -3,6 +3,7 @@
3
3
  require_relative "../errors"
4
4
  require_relative "../fs"
5
5
  require_relative "../platform"
6
+ require_relative "../spec_utils"
6
7
  require_relative "../progress"
7
8
  require_relative "../worker_pool"
8
9
  require_relative "../scheduler"
@@ -32,6 +33,8 @@ require_relative "../resolver/provider"
32
33
  require_relative "../resolver/resolver"
33
34
  require_relative "../credentials"
34
35
  require "open3"
36
+ require "set"
37
+ require "pathname"
35
38
 
36
39
  module Scint
37
40
  module CLI
@@ -44,6 +47,10 @@ module Scint
44
47
  @path = nil
45
48
  @verbose = false
46
49
  @force = false
50
+ @download_pool = nil
51
+ @download_pool_lock = Thread::Mutex.new
52
+ @gemspec_cache = {}
53
+ @gemspec_cache_lock = Thread::Mutex.new
47
54
  parse_options
48
55
  end
49
56
 
@@ -52,10 +59,13 @@ module Scint
52
59
 
53
60
  cache = Scint::Cache::Layout.new
54
61
  bundle_path = @path || ENV["BUNDLER_PATH"] || ".bundle"
62
+ bundle_display = display_bundle_path(bundle_path)
55
63
  bundle_path = File.expand_path(bundle_path)
56
64
  worker_count = @jobs || [Platform.cpu_count * 2, 50].min
57
65
  compile_slots = compile_slots_for(worker_count)
58
66
  per_type_limits = install_task_limits(worker_count, compile_slots)
67
+ $stdout.puts "#{GREEN}💎#{RESET} Scintellating Gemfile into #{BOLD}#{bundle_display}#{RESET} #{DIM}(scint #{VERSION}, ruby #{RUBY_VERSION})#{RESET}"
68
+ $stdout.puts
59
69
 
60
70
  # 0. Build credential store from config files (~/.bundle/config, XDG scint/credentials)
61
71
  @credentials = Credentials.new
@@ -114,10 +124,18 @@ module Scint
114
124
  # Scale up for download/install phase based on actual work count
115
125
  scheduler.scale_workers(to_install.size)
116
126
 
127
+ # Warm-cache accelerator: pre-materialize cache-backed gem trees in
128
+ # batches so install workers avoid one cp process per gem.
129
+ bulk_prelink_gem_files(to_install, cache, bundle_path)
130
+
117
131
  if to_install.empty?
132
+ # Keep lock artifacts aligned even when everything is already installed.
133
+ write_lockfile(resolved, gemfile, lockfile)
134
+ write_runtime_config(resolved, bundle_path)
118
135
  elapsed_ms = elapsed_ms_since(start_time)
136
+ worker_count = scheduler.stats[:workers]
119
137
  warn_missing_bundle_gitignore_entry
120
- $stdout.puts "\n#{GREEN}#{total_gems}#{RESET} gems installed total#{install_breakdown(cached: cached_gems, updated: updated_gems)}. #{DIM}(#{format_elapsed(elapsed_ms)})#{RESET}"
138
+ $stdout.puts "\n#{GREEN}#{total_gems}#{RESET} gems installed total#{install_breakdown(cached: cached_gems, updated: updated_gems)}. #{DIM}(#{format_run_footer(elapsed_ms, worker_count)})#{RESET}"
121
139
  return 0
122
140
  end
123
141
 
@@ -144,13 +162,16 @@ module Scint
144
162
  if errors.any?
145
163
  $stderr.puts "#{RED}Some gems failed to install:#{RESET}"
146
164
  errors.each do |err|
147
- $stderr.puts " #{BOLD}#{err[:name]}#{RESET}: #{err[:error].message}"
165
+ error = err[:error]
166
+ $stderr.puts " #{BOLD}#{err[:name]}#{RESET}: #{error.message}"
167
+ emit_network_error_details(error)
148
168
  end
149
169
  elsif stats[:failed] > 0
150
170
  $stderr.puts "#{YELLOW}Warning: #{stats[:failed]} jobs failed but no error details captured#{RESET}"
151
171
  end
152
172
 
153
173
  elapsed_ms = elapsed_ms_since(start_time)
174
+ worker_count = stats[:workers]
154
175
  failed = errors.filter_map { |e| e[:name] }.uniq
155
176
  failed_count = failed.size
156
177
  failed_count = 1 if failed_count.zero? && stats[:failed] > 0
@@ -159,18 +180,22 @@ module Scint
159
180
 
160
181
  if has_failures
161
182
  warn_missing_bundle_gitignore_entry
162
- $stdout.puts "\n#{RED}Bundle failed!#{RESET} #{installed_total}/#{total_gems} gems installed total#{install_breakdown(cached: cached_gems, updated: updated_gems, compiled: compiled_gems, failed: failed_count)}. #{DIM}(#{format_elapsed(elapsed_ms)})#{RESET}"
183
+ $stdout.puts "\n#{RED}Bundle failed!#{RESET} #{installed_total}/#{total_gems} gems installed total#{install_breakdown(cached: cached_gems, updated: updated_gems, compiled: compiled_gems, failed: failed_count)}. #{DIM}(#{format_run_footer(elapsed_ms, worker_count)})#{RESET}"
163
184
  1
164
185
  else
165
186
  # 10. Write lockfile + runtime config only for successful installs
166
- write_lockfile(resolved, gemfile)
187
+ write_lockfile(resolved, gemfile, lockfile)
167
188
  write_runtime_config(resolved, bundle_path)
168
189
  warn_missing_bundle_gitignore_entry
169
- $stdout.puts "\n#{GREEN}#{total_gems}#{RESET} gems installed total#{install_breakdown(cached: cached_gems, updated: updated_gems, compiled: compiled_gems)}. #{DIM}(#{format_elapsed(elapsed_ms)})#{RESET}"
190
+ $stdout.puts "\n#{GREEN}#{total_gems}#{RESET} gems installed total#{install_breakdown(cached: cached_gems, updated: updated_gems, compiled: compiled_gems)}. #{DIM}(#{format_run_footer(elapsed_ms, worker_count)})#{RESET}"
170
191
  0
171
192
  end
172
193
  ensure
173
- scheduler.shutdown
194
+ begin
195
+ scheduler.shutdown
196
+ ensure
197
+ close_download_pool
198
+ end
174
199
  end
175
200
  end
176
201
 
@@ -194,7 +219,7 @@ module Scint
194
219
  remote_uri: nil,
195
220
  checksum: nil,
196
221
  )
197
- resolved << scint_spec
222
+ resolved.unshift(scint_spec)
198
223
 
199
224
  resolved
200
225
  end
@@ -202,7 +227,7 @@ module Scint
202
227
  def dedupe_resolved_specs(resolved)
203
228
  seen = {}
204
229
  resolved.each do |spec|
205
- key = "#{spec.name}-#{spec.version}-#{spec.platform}"
230
+ key = SpecUtils.full_key(spec)
206
231
  seen[key] ||= spec
207
232
  end
208
233
  seen.values
@@ -212,8 +237,8 @@ module Scint
212
237
  # No download needed — we know exactly where we are.
213
238
  def install_builtin_gem(entry, bundle_path)
214
239
  spec = entry.spec
215
- ruby_dir = File.join(bundle_path, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
216
- full_name = spec_full_name(spec)
240
+ ruby_dir = Platform.ruby_install_dir(bundle_path)
241
+ full_name = SpecUtils.full_name(spec)
217
242
  scint_root = File.expand_path("../../..", __FILE__)
218
243
 
219
244
  # Copy gem files into gems/scint-x.y.z/lib/
@@ -221,7 +246,7 @@ module Scint
221
246
  lib_dest = File.join(gem_dest, "lib")
222
247
  unless Dir.exist?(lib_dest)
223
248
  FS.mkdir_p(lib_dest)
224
- FS.hardlink_tree(scint_root, lib_dest)
249
+ FS.clone_tree(scint_root, lib_dest)
225
250
  end
226
251
 
227
252
  # Write gemspec
@@ -255,16 +280,20 @@ module Scint
255
280
  def clone_git_source(source, cache)
256
281
  return unless source.respond_to?(:uri)
257
282
  git_dir = cache.git_path(source.uri)
258
- return if Dir.exist?(git_dir)
283
+ if Dir.exist?(git_dir)
284
+ fetch_git_repo(git_dir)
285
+ return
286
+ end
259
287
 
260
- FS.mkdir_p(File.dirname(git_dir))
261
- system("git", "clone", "--bare", source.uri.to_s, git_dir,
262
- [:out, :err] => File::NULL)
288
+ clone_git_repo(source.uri, git_dir)
263
289
  end
264
290
 
265
291
  def resolve(gemfile, lockfile, cache)
266
292
  # If lockfile is up-to-date, use its specs directly
267
- if lockfile && lockfile_current?(gemfile, lockfile)
293
+ if lockfile &&
294
+ lockfile_current?(gemfile, lockfile) &&
295
+ lockfile_dependency_graph_valid?(lockfile) &&
296
+ lockfile_git_source_mapping_valid?(lockfile, cache)
268
297
  return lockfile_to_resolved(lockfile)
269
298
  end
270
299
 
@@ -299,6 +328,7 @@ module Scint
299
328
  # Build path_gems: gem_name => { version:, dependencies:, source: }
300
329
  # for gems with path: or git: sources (skip compact index for these)
301
330
  path_gems = {}
331
+ git_source_metadata_cache = {}
302
332
  gemfile.dependencies.each do |dep|
303
333
  opts = dep.source_options
304
334
  next unless opts[:path] || opts[:git]
@@ -308,19 +338,57 @@ module Scint
308
338
 
309
339
  # Try to read version and deps from gemspec if it's a path gem
310
340
  if opts[:path]
311
- gemspec = find_gemspec(opts[:path], dep.name)
341
+ gemspec = find_gemspec(opts[:path], dep.name, glob: opts[:glob])
312
342
  if gemspec
313
343
  version = gemspec.version.to_s
314
344
  deps = gemspec.dependencies
315
345
  .select { |d| d.type == :runtime }
316
- .map { |d| [d.name, d.requirement.to_s] }
346
+ .map do |d|
347
+ requirement_parts = d.requirement.requirements.map { |op, req_version| "#{op} #{req_version}" }
348
+ [d.name, requirement_parts]
349
+ end
317
350
  end
318
351
  end
319
352
 
320
- # For git gems, try lockfile for version
321
- if opts[:git] && lockfile
322
- locked_spec = lockfile.specs.find { |s| s[:name] == dep.name }
323
- version = locked_spec[:version] if locked_spec
353
+ # For git gems, read version and dependencies from the available revision when possible.
354
+ if opts[:git]
355
+ git_source = find_matching_git_source(Array(lockfile&.sources), opts) || find_matching_git_source(gemfile.sources, opts)
356
+ revision_hint = git_source&.revision || git_source&.ref || opts[:ref] || opts[:branch] || opts[:tag] || "HEAD"
357
+ bare_repo = cache&.git_path(opts[:git])
358
+ if bare_repo && !Dir.exist?(bare_repo)
359
+ clone_git_repo(opts[:git], bare_repo)
360
+ elsif bare_repo && Dir.exist?(bare_repo)
361
+ fetch_git_repo(bare_repo)
362
+ end
363
+ if bare_repo && Dir.exist?(bare_repo)
364
+ begin
365
+ resolved_revision = resolve_git_revision(bare_repo, revision_hint)
366
+ cache_key = "#{opts[:git]}@#{resolved_revision}"
367
+ git_metadata = git_source_metadata_cache[cache_key]
368
+ unless git_metadata
369
+ git_metadata = build_git_path_gems_for_revision(
370
+ bare_repo,
371
+ resolved_revision,
372
+ glob: opts[:glob],
373
+ source_desc: opts[:git],
374
+ )
375
+ git_source_metadata_cache[cache_key] = git_metadata
376
+ end
377
+ path_gems.merge!(git_metadata)
378
+ current = git_metadata[dep.name]
379
+ if current
380
+ version = current[:version]
381
+ deps = current[:dependencies]
382
+ end
383
+ rescue StandardError
384
+ # Fall back to lockfile version below.
385
+ end
386
+ end
387
+
388
+ if lockfile && version == "0"
389
+ locked_spec = lockfile.specs.find { |s| s[:name] == dep.name }
390
+ version = locked_spec[:version] if locked_spec
391
+ end
324
392
  end
325
393
 
326
394
  source_desc = opts[:path] || opts[:git] || "local"
@@ -347,14 +415,16 @@ module Scint
347
415
  resolver.resolve
348
416
  end
349
417
 
350
- def find_gemspec(path, gem_name)
418
+ def find_gemspec(path, gem_name, glob: nil)
351
419
  return nil unless Dir.exist?(path)
352
420
 
421
+ glob_pattern = glob || Source::Path::DEFAULT_GLOB
353
422
  # Look for exact match first, then any gemspec
354
423
  candidates = [
355
424
  File.join(path, "#{gem_name}.gemspec"),
425
+ *Dir.glob(File.join(path, glob_pattern)),
356
426
  *Dir.glob(File.join(path, "*.gemspec")),
357
- ]
427
+ ].uniq
358
428
 
359
429
  candidates.each do |gs|
360
430
  next unless File.exist?(gs)
@@ -368,16 +438,148 @@ module Scint
368
438
  nil
369
439
  end
370
440
 
441
+ def find_git_gemspec(bare_repo, revision, gem_name, glob: nil)
442
+ gemspec_paths = gemspec_paths_in_git_revision(bare_repo, revision)
443
+ return nil if gemspec_paths.empty?
444
+
445
+ path = gemspec_paths[gem_name.to_s]
446
+ if path.nil? && glob
447
+ glob_regex = git_glob_to_regex(glob)
448
+ path = gemspec_paths.values.find { |candidate| candidate.match?(glob_regex) }
449
+ end
450
+ path ||= gemspec_paths.values.first
451
+ return nil if path.nil?
452
+
453
+ load_git_gemspec(bare_repo, revision, path)
454
+ rescue StandardError
455
+ nil
456
+ end
457
+
458
+ def build_git_path_gems_for_revision(bare_repo, revision, glob: nil, source_desc: nil)
459
+ gemspec_paths = gemspec_paths_in_git_revision(bare_repo, revision)
460
+ return {} if gemspec_paths.empty?
461
+
462
+ glob_regex = glob ? git_glob_to_regex(glob) : nil
463
+ data = {}
464
+
465
+ with_git_worktree(bare_repo, revision) do |worktree|
466
+ gemspec_paths.each_value do |path|
467
+ next if glob_regex && !path.match?(glob_regex)
468
+
469
+ gemspec = load_gemspec_from_worktree(worktree, path)
470
+ next unless gemspec
471
+
472
+ deps = gemspec.dependencies
473
+ .select { |d| d.type == :runtime }
474
+ .map do |d|
475
+ requirement_parts = d.requirement.requirements.map { |op, req_version| "#{op} #{req_version}" }
476
+ [d.name, requirement_parts]
477
+ end
478
+
479
+ data[gemspec.name] = {
480
+ version: gemspec.version.to_s,
481
+ dependencies: deps,
482
+ source: source_desc || "local",
483
+ }
484
+ end
485
+ end
486
+
487
+ data
488
+ rescue StandardError
489
+ {}
490
+ end
491
+
492
+ def git_glob_to_regex(glob)
493
+ pattern = glob.to_s
494
+ escaped = Regexp.escape(pattern)
495
+ escaped = escaped.gsub("\\*\\*", ".*")
496
+ escaped = escaped.gsub("\\*", "[^/]*")
497
+ escaped = escaped.gsub("\\?", ".")
498
+ /\A#{escaped}\z/
499
+ end
500
+
371
501
  def lockfile_current?(gemfile, lockfile)
372
502
  return false unless lockfile
373
503
 
374
504
  locked_names = Set.new(lockfile.specs.map { |s| s[:name] })
375
- gemfile.dependencies.all? { |d| locked_names.include?(d.name) }
505
+ gemfile.dependencies.all? do |dep|
506
+ next true unless dependency_relevant_for_local_platform?(dep)
507
+
508
+ locked_names.include?(dep.name)
509
+ end
510
+ end
511
+
512
+ def lockfile_dependency_graph_valid?(lockfile)
513
+ return false unless lockfile
514
+
515
+ specs = Array(lockfile.specs)
516
+ return false if specs.empty?
517
+
518
+ by_name = Hash.new { |h, k| h[k] = [] }
519
+ specs.each { |spec| by_name[spec[:name].to_s] << spec }
520
+
521
+ specs.all? do |spec|
522
+ Array(spec[:dependencies]).all? do |dep|
523
+ dep_name = dep[:name].to_s
524
+ # Lockfiles generally do not include a concrete "bundler" spec
525
+ # entry even though many gemspecs declare a runtime dependency
526
+ # on bundler. Treat it as externally satisfied.
527
+ next true if dep_name == "bundler"
528
+
529
+ dep_reqs = Array(dep[:version_reqs])
530
+ req = Gem::Requirement.new(dep_reqs.empty? ? [">= 0"] : dep_reqs)
531
+ by_name[dep_name].any? { |candidate| req.satisfied_by?(Gem::Version.new(candidate[:version].to_s)) }
532
+ end
533
+ end
534
+ rescue StandardError
535
+ false
536
+ end
537
+
538
+ def dependency_relevant_for_local_platform?(dependency)
539
+ platforms = Array(dependency.platforms).map(&:to_sym)
540
+ return true if platforms.empty?
541
+
542
+ platforms.any? { |platform| gemfile_platform_matches_local?(platform) }
543
+ end
544
+
545
+ def gemfile_platform_matches_local?(platform)
546
+ case platform
547
+ when :ruby
548
+ true
549
+ when :mri
550
+ RUBY_ENGINE == "ruby"
551
+ when :jruby
552
+ RUBY_ENGINE == "jruby"
553
+ when :truffleruby
554
+ RUBY_ENGINE == "truffleruby"
555
+ when :rbx
556
+ RUBY_ENGINE == "rbx"
557
+ when :windows, :mswin, :mswin64, :mingw, :x64_mingw, :x86_mingw, :x64_mingw_ucrt
558
+ Platform.windows?
559
+ when :linux
560
+ Platform.linux?
561
+ when :darwin, :macos
562
+ Platform.macos?
563
+ else
564
+ platform_name = platform.to_s.tr("_", "-")
565
+ spec_platform = Gem::Platform.new(platform_name)
566
+ spec_platform === Platform.local_platform
567
+ end
568
+ rescue StandardError
569
+ false
376
570
  end
377
571
 
378
572
  def lockfile_to_resolved(lockfile)
379
- lockfile.specs.map do |ls|
380
- source = ls[:source]
573
+ local_plat = Platform.local_platform
574
+
575
+ # Pick one best platform variant per gem+version from lockfile specs.
576
+ by_gem = Hash.new { |h, k| h[k] = [] }
577
+ lockfile.specs.each { |ls| by_gem[[ls[:name], ls[:version]]] << ls }
578
+
579
+ resolved = by_gem.map do |(_name, _version), specs|
580
+ best = pick_best_platform_spec(specs, local_plat)
581
+
582
+ source = best[:source]
381
583
  source_value =
382
584
  if source.is_a?(Source::Rubygems)
383
585
  source.uri.to_s
@@ -386,23 +588,205 @@ module Scint
386
588
  end
387
589
 
388
590
  ResolvedSpec.new(
389
- name: ls[:name],
390
- version: ls[:version],
391
- platform: ls[:platform],
392
- dependencies: ls[:dependencies],
591
+ name: best[:name],
592
+ version: best[:version],
593
+ platform: best[:platform],
594
+ dependencies: best[:dependencies],
393
595
  source: source_value,
394
596
  has_extensions: false,
395
597
  remote_uri: nil,
396
- checksum: ls[:checksum],
598
+ checksum: best[:checksum],
397
599
  )
398
600
  end
601
+
602
+ apply_locked_platform_preferences(resolved)
603
+ end
604
+
605
+ def lockfile_git_source_mapping_valid?(lockfile, cache)
606
+ return true unless lockfile && cache
607
+
608
+ git_specs = Array(lockfile.specs).select { |s| s[:source].is_a?(Source::Git) }
609
+ return true if git_specs.empty?
610
+
611
+ by_source = git_specs.group_by { |s| s[:source] }
612
+ by_source.each do |source, specs|
613
+ uri, revision = git_source_ref(source)
614
+ bare_repo = cache.git_path(uri)
615
+ # Do not invalidate an otherwise-usable lockfile just because this
616
+ # git source has not been cached yet in the current machine/session.
617
+ next unless Dir.exist?(bare_repo)
618
+
619
+ resolved_revision = begin
620
+ resolve_git_revision(bare_repo, revision)
621
+ rescue InstallError
622
+ nil
623
+ end
624
+ return false unless resolved_revision
625
+
626
+ gemspec_paths = gemspec_paths_in_git_revision(bare_repo, resolved_revision)
627
+ gemspec_names = gemspec_paths.keys.to_set
628
+ return false if gemspec_names.empty?
629
+
630
+ specs.each do |spec|
631
+ return false unless gemspec_names.include?(spec[:name].to_s)
632
+ end
633
+ end
634
+
635
+ true
636
+ end
637
+
638
+ def gemspec_paths_in_git_revision(bare_repo, revision)
639
+ out, _err, status = git_capture3(
640
+ "--git-dir", bare_repo,
641
+ "ls-tree",
642
+ "-r",
643
+ "--name-only",
644
+ revision,
645
+ )
646
+ return {} unless status.success?
647
+
648
+ paths = {}
649
+ out.each_line do |line|
650
+ path = line.strip
651
+ next unless path.end_with?(".gemspec")
652
+ name = File.basename(path, ".gemspec")
653
+ paths[name] ||= path
654
+ end
655
+ paths
656
+ rescue StandardError
657
+ {}
658
+ end
659
+
660
+ def runtime_dependencies_for_git_gemspec(bare_repo, revision, gemspec_path)
661
+ spec = load_git_gemspec(bare_repo, revision, gemspec_path)
662
+ return nil unless spec
663
+
664
+ spec.dependencies.select { |dep| dep.type == :runtime }
665
+ rescue StandardError
666
+ nil
667
+ end
668
+
669
+ def load_git_gemspec(bare_repo, revision, gemspec_path)
670
+ return nil if gemspec_path.to_s.empty?
671
+
672
+ with_git_worktree(bare_repo, revision) do |worktree|
673
+ load_gemspec_from_worktree(worktree, gemspec_path)
674
+ end
675
+ rescue StandardError
676
+ nil
677
+ end
678
+
679
+ def with_git_worktree(bare_repo, revision)
680
+ worktree = Dir.mktmpdir("scint-gemspec")
681
+ _out, _err, status = git_capture3(
682
+ "--git-dir", bare_repo,
683
+ "--work-tree", worktree,
684
+ "checkout",
685
+ "--force",
686
+ revision,
687
+ )
688
+ return nil unless status.success?
689
+
690
+ File.write(File.join(worktree, ".git"), "gitdir: #{bare_repo}\n")
691
+ yield worktree if block_given?
692
+ ensure
693
+ FileUtils.rm_rf(worktree) if worktree && !worktree.empty?
694
+ end
695
+
696
+ def load_gemspec_from_worktree(worktree, gemspec_path)
697
+ absolute_gemspec = File.join(worktree, gemspec_path)
698
+ return nil unless File.exist?(absolute_gemspec)
699
+
700
+ Dir.chdir(File.dirname(absolute_gemspec)) do
701
+ Gem::Specification.load(absolute_gemspec)
702
+ end
703
+ rescue StandardError
704
+ nil
705
+ end
706
+
707
+ # Preference: exact platform match > compatible match > ruby > first.
708
+ def pick_best_platform_spec(specs, local_plat)
709
+ return specs.first if specs.size == 1
710
+
711
+ best = nil
712
+ best_score = -2
713
+
714
+ specs.each do |ls|
715
+ platform = ls[:platform] || "ruby"
716
+ if platform == "ruby"
717
+ score = 0
718
+ else
719
+ spec_plat = Gem::Platform.new(platform)
720
+ if spec_plat === local_plat
721
+ score = spec_plat.to_s == local_plat.to_s ? 2 : 1
722
+ else
723
+ score = -1
724
+ end
725
+ end
726
+
727
+ if score > best_score
728
+ best = ls
729
+ best_score = score
730
+ end
731
+ end
732
+
733
+ best
734
+ end
735
+
736
+ # Lockfiles can carry only the ruby variant for a gem version.
737
+ # Re-check compact index for the same locked version and upgrade to the
738
+ # best local platform variant when available.
739
+ def apply_locked_platform_preferences(resolved_specs)
740
+ preferred = preferred_platforms_for_locked_specs(resolved_specs)
741
+ return resolved_specs if preferred.empty?
742
+
743
+ resolved_specs.each do |spec|
744
+ key = SpecUtils.full_name_for(spec.name, spec.version)
745
+ platform = preferred[key]
746
+ next if platform.nil? || platform.empty?
747
+
748
+ spec.platform = platform
749
+ end
750
+
751
+ resolved_specs
752
+ end
753
+
754
+ def preferred_platforms_for_locked_specs(resolved_specs)
755
+ out = {}
756
+ by_source = resolved_specs
757
+ .select { |spec| rubygems_source_uri?(spec.source) }
758
+ .group_by { |spec| spec.source.to_s.chomp("/") }
759
+
760
+ by_source.each do |source_uri, specs|
761
+ begin
762
+ client = Index::Client.new(source_uri, credentials: @credentials)
763
+ provider = Resolver::Provider.new(client)
764
+ provider.prefetch(specs.map(&:name).uniq)
765
+
766
+ specs.each do |spec|
767
+ preferred = provider.preferred_platform_for(spec.name, Gem::Version.new(spec.version.to_s))
768
+ preferred = preferred.to_s
769
+ next if preferred.empty? || preferred == spec.platform.to_s
770
+
771
+ out[SpecUtils.full_name_for(spec.name, spec.version)] = preferred
772
+ end
773
+ rescue StandardError
774
+ next
775
+ end
776
+ end
777
+
778
+ out
779
+ end
780
+
781
+ def rubygems_source_uri?(source)
782
+ source.is_a?(String) && source.match?(%r{\Ahttps?://})
399
783
  end
400
784
 
401
785
  def download_gem(entry, cache)
402
786
  spec = entry.spec
403
787
  source = spec.source
404
788
  if git_source?(source)
405
- prepare_git_source(entry, cache)
789
+ prepare_git_checkout(spec, cache, fetch: false)
406
790
  return
407
791
  end
408
792
  source_uri = source.to_s
@@ -410,7 +794,7 @@ module Scint
410
794
  # Path gems are not downloaded from a remote
411
795
  return if source_uri.start_with?("/") || !source_uri.start_with?("http")
412
796
 
413
- full_name = spec_full_name(spec)
797
+ full_name = SpecUtils.full_name(spec)
414
798
  gem_filename = "#{full_name}.gem"
415
799
  source_uri = source_uri.chomp("/")
416
800
  download_uri = "#{source_uri}/gems/#{gem_filename}"
@@ -419,9 +803,20 @@ module Scint
419
803
  FS.mkdir_p(File.dirname(dest_path))
420
804
 
421
805
  unless File.exist?(dest_path)
422
- pool = Downloader::Pool.new(size: 1, credentials: @credentials)
423
- pool.download(download_uri, dest_path)
424
- pool.close
806
+ downloader_pool.download(download_uri, dest_path)
807
+ end
808
+ end
809
+
810
+ def downloader_pool
811
+ @download_pool_lock.synchronize do
812
+ @download_pool ||= Downloader::Pool.new(credentials: @credentials)
813
+ end
814
+ end
815
+
816
+ def close_download_pool
817
+ @download_pool_lock.synchronize do
818
+ @download_pool&.close
819
+ @download_pool = nil
425
820
  end
426
821
  end
427
822
 
@@ -429,8 +824,12 @@ module Scint
429
824
  spec = entry.spec
430
825
  source_uri = spec.source.to_s
431
826
 
432
- # Git/path gems are already materialized by checkout or local path.
433
- return if git_source?(spec.source)
827
+ # Git gems are extracted from the cached checkout; path gems are
828
+ # linked directly from local source.
829
+ if git_source?(spec.source)
830
+ materialize_git_spec(entry, cache)
831
+ return
832
+ end
434
833
  return if source_uri.start_with?("/") || !source_uri.start_with?("http")
435
834
 
436
835
  extracted = cache.extracted_path(spec)
@@ -452,36 +851,132 @@ module Scint
452
851
  source_str.end_with?(".git") || source_str.include?(".git/")
453
852
  end
454
853
 
854
+ def path_source?(source)
855
+ return true if source.is_a?(Source::Path)
856
+
857
+ source_str =
858
+ if source.respond_to?(:path)
859
+ source.path.to_s
860
+ else
861
+ source.to_s
862
+ end
863
+ return false if source_str.empty?
864
+ return false if source_str.start_with?("http://", "https://")
865
+ return false if git_source?(source)
866
+
867
+ source_str.start_with?("/", ".", "~")
868
+ end
869
+
455
870
  def prepare_git_source(entry, cache)
871
+ # Legacy helper used by tests/callers that expect git download+extract
872
+ # in a single step.
456
873
  spec = entry.spec
874
+ checkout_dir, resolved_revision, _uri = prepare_git_checkout(spec, cache, fetch: true)
875
+ materialize_git_spec(entry, cache, checkout_dir: checkout_dir, resolved_revision: resolved_revision)
876
+ end
877
+
878
+ def prepare_git_checkout(spec, cache, fetch: false)
457
879
  source = spec.source
458
880
  uri, revision = git_source_ref(source)
881
+ submodules = git_source_submodules?(source)
459
882
 
460
883
  bare_repo = cache.git_path(uri)
461
884
 
462
885
  # Serialize all git operations per bare repo — git uses index.lock
463
886
  # and can't handle concurrent checkouts from the same repo.
464
887
  git_mutex_for(bare_repo).synchronize do
465
- clone_git_repo(uri, bare_repo) unless Dir.exist?(bare_repo)
888
+ if Dir.exist?(bare_repo)
889
+ fetch_git_repo(bare_repo) if fetch
890
+ else
891
+ clone_git_repo(uri, bare_repo)
892
+ fetch_git_repo(bare_repo)
893
+ end
894
+
895
+ resolved_revision = resolve_git_revision(bare_repo, revision)
896
+ incoming_checkout = cache.git_checkout_path(uri, resolved_revision)
897
+ materialize_git_checkout(
898
+ bare_repo,
899
+ incoming_checkout,
900
+ resolved_revision,
901
+ spec,
902
+ uri,
903
+ submodules: submodules,
904
+ )
905
+ [incoming_checkout, resolved_revision, uri]
906
+ end
907
+ end
466
908
 
467
- extracted = cache.extracted_path(spec)
468
- return if Dir.exist?(extracted)
909
+ def materialize_git_spec(entry, cache, checkout_dir: nil, resolved_revision: nil)
910
+ spec = entry.spec
911
+ checkout_dir, resolved_revision, _uri = prepare_git_checkout(spec, cache, fetch: false) unless checkout_dir && resolved_revision
912
+ gem_root = resolve_git_gem_subdir(checkout_dir, spec)
913
+ extracted = cache.extracted_path(spec)
914
+ marker = git_checkout_marker_path(extracted)
915
+ if Dir.exist?(extracted) &&
916
+ File.exist?(marker) &&
917
+ File.read(marker).strip == resolved_revision &&
918
+ git_spec_layout_current?(extracted, spec)
919
+ return
920
+ end
469
921
 
470
- tmp = "#{extracted}.#{Process.pid}.#{Thread.current.object_id}.tmp"
471
- begin
472
- FileUtils.rm_rf(tmp)
473
- FS.mkdir_p(tmp)
922
+ tmp = "#{extracted}.#{Process.pid}.#{Thread.current.object_id}.tmp"
923
+ begin
924
+ FileUtils.rm_rf(tmp)
925
+ FS.clone_tree(gem_root, tmp)
474
926
 
475
- cmd = ["git", "--git-dir", bare_repo, "--work-tree", tmp, "checkout", "-f", revision, "--", "."]
476
- _out, err, status = Open3.capture3(*cmd)
477
- unless status.success?
478
- raise InstallError, "Git checkout failed for #{spec.name} (#{uri}@#{revision}): #{err.to_s.strip}"
479
- end
927
+ FileUtils.rm_rf(extracted)
928
+ FS.atomic_move(tmp, extracted)
929
+ FS.atomic_write(marker, "#{resolved_revision}\n")
930
+ ensure
931
+ FileUtils.rm_rf(tmp) if tmp && File.exist?(tmp)
932
+ end
933
+ end
480
934
 
481
- FS.atomic_move(tmp, extracted)
482
- ensure
483
- FileUtils.rm_rf(tmp) if tmp && File.exist?(tmp)
935
+ def git_spec_layout_current?(extracted_path, spec)
936
+ File.exist?(File.join(extracted_path, "#{spec.name}.gemspec"))
937
+ rescue StandardError
938
+ false
939
+ end
940
+
941
+ def materialize_git_checkout(bare_repo, checkout_dir, resolved_revision, spec, uri, submodules: false)
942
+ marker = git_checkout_marker_path(checkout_dir)
943
+ if Dir.exist?(checkout_dir) && File.exist?(marker)
944
+ checkout_marker = parse_git_checkout_marker(marker)
945
+ marker_revision = checkout_marker[:revision]
946
+ marker_submodules = checkout_marker[:submodules]
947
+
948
+ # Legacy markers only recorded revision. If this source now requires
949
+ # submodules, force a refresh so previously incomplete checkouts are
950
+ # not reused.
951
+ return if marker_revision == resolved_revision && (!submodules || marker_submodules == true)
952
+ end
953
+
954
+ tmp = "#{checkout_dir}.#{Process.pid}.#{Thread.current.object_id}.tmp"
955
+ begin
956
+ FileUtils.rm_rf(tmp)
957
+ if submodules
958
+ checkout_git_tree_with_submodules(
959
+ bare_repo,
960
+ tmp,
961
+ resolved_revision,
962
+ spec,
963
+ uri,
964
+ )
965
+ else
966
+ checkout_git_tree(
967
+ bare_repo,
968
+ tmp,
969
+ resolved_revision,
970
+ spec,
971
+ uri,
972
+ )
484
973
  end
974
+
975
+ FileUtils.rm_rf(checkout_dir)
976
+ FS.atomic_move(tmp, checkout_dir)
977
+ FS.atomic_write(marker, format_git_checkout_marker(resolved_revision, submodules: submodules))
978
+ ensure
979
+ FileUtils.rm_rf(tmp) if tmp && File.exist?(tmp)
485
980
  end
486
981
  end
487
982
 
@@ -494,6 +989,68 @@ module Scint
494
989
  [source.to_s, "HEAD"]
495
990
  end
496
991
 
992
+ def git_source_submodules?(source)
993
+ source.respond_to?(:submodules) && !!source.submodules
994
+ end
995
+
996
+ def checkout_git_tree(bare_repo, destination, resolved_revision, spec, uri)
997
+ FileUtils.mkdir_p(destination)
998
+ _out, err, status = git_capture3(
999
+ "--git-dir", bare_repo,
1000
+ "--work-tree", destination,
1001
+ "checkout",
1002
+ "-f",
1003
+ resolved_revision,
1004
+ "--",
1005
+ ".",
1006
+ )
1007
+ unless status.success?
1008
+ raise InstallError, "Git checkout failed for #{spec.name} (#{uri}@#{resolved_revision}): #{err.to_s.strip}"
1009
+ end
1010
+ end
1011
+
1012
+ def checkout_git_tree_with_submodules(bare_repo, destination, resolved_revision, spec, uri)
1013
+ worktree = "#{destination}.worktree"
1014
+ FileUtils.rm_rf(worktree)
1015
+
1016
+ _out, err, status = git_capture3(
1017
+ "--git-dir", bare_repo,
1018
+ "worktree",
1019
+ "add",
1020
+ "--detach",
1021
+ "--force",
1022
+ worktree,
1023
+ resolved_revision,
1024
+ )
1025
+ unless status.success?
1026
+ raise InstallError, "Git worktree checkout failed for #{spec.name} (#{uri}@#{resolved_revision}): #{err.to_s.strip}"
1027
+ end
1028
+
1029
+ begin
1030
+ _sub_out, sub_err, sub_status = git_capture3(
1031
+ "-C", worktree,
1032
+ "-c", "protocol.file.allow=always",
1033
+ "submodule",
1034
+ "update",
1035
+ "--init",
1036
+ "--recursive",
1037
+ )
1038
+ unless sub_status.success?
1039
+ raise InstallError, "Git submodule update failed for #{spec.name} (#{uri}@#{resolved_revision}): #{sub_err.to_s.strip}"
1040
+ end
1041
+
1042
+ FS.clone_tree(worktree, destination)
1043
+
1044
+ # Keep cache/extracted trees deterministic and detached from git internals.
1045
+ Dir.glob(File.join(destination, "**", ".git"), File::FNM_DOTMATCH).each do |path|
1046
+ FileUtils.rm_rf(path)
1047
+ end
1048
+ ensure
1049
+ git_capture3("--git-dir", bare_repo, "worktree", "remove", "--force", worktree)
1050
+ FileUtils.rm_rf(worktree)
1051
+ end
1052
+ end
1053
+
497
1054
  def git_mutex_for(repo_path)
498
1055
  @git_mutexes_lock ||= Thread::Mutex.new
499
1056
  @git_mutexes_lock.synchronize do
@@ -504,26 +1061,89 @@ module Scint
504
1061
 
505
1062
  def clone_git_repo(uri, bare_repo)
506
1063
  FS.mkdir_p(File.dirname(bare_repo))
507
- _out, err, status = Open3.capture3("git", "clone", "--bare", uri.to_s, bare_repo)
1064
+ _out, err, status = git_capture3("clone", "--bare", uri.to_s, bare_repo)
508
1065
  unless status.success?
509
1066
  raise InstallError, "Git clone failed for #{uri}: #{err.to_s.strip}"
510
1067
  end
511
1068
  end
512
1069
 
1070
+ def fetch_git_repo(bare_repo)
1071
+ _out, err, status = git_capture3(
1072
+ "--git-dir", bare_repo,
1073
+ "fetch",
1074
+ "--prune",
1075
+ "origin",
1076
+ "+refs/heads/*:refs/heads/*",
1077
+ "+refs/tags/*:refs/tags/*",
1078
+ )
1079
+ unless status.success?
1080
+ raise InstallError, "Git fetch failed for #{bare_repo}: #{err.to_s.strip}"
1081
+ end
1082
+ end
1083
+
1084
+ def resolve_git_revision(bare_repo, revision)
1085
+ out, err, status = git_capture3("--git-dir", bare_repo, "rev-parse", "#{revision}^{commit}")
1086
+ unless status.success?
1087
+ raise InstallError, "Unable to resolve git revision #{revision.inspect} in #{bare_repo}: #{err.to_s.strip}"
1088
+ end
1089
+ out.strip
1090
+ end
1091
+
1092
+ def git_capture3(*args)
1093
+ Open3.capture3("git", "-c", "core.fsmonitor=false", *args)
1094
+ end
1095
+
1096
+ def git_checkout_marker_path(dir)
1097
+ "#{dir}.scint_git_revision"
1098
+ end
1099
+
1100
+ def parse_git_checkout_marker(path)
1101
+ content = File.read(path)
1102
+ revision = nil
1103
+ submodules = nil
1104
+
1105
+ content.each_line do |line|
1106
+ key, value = line.strip.split("=", 2)
1107
+ case key
1108
+ when "revision"
1109
+ revision = value
1110
+ when "submodules"
1111
+ submodules = (value == "1" || value == "true")
1112
+ end
1113
+ end
1114
+
1115
+ if revision.nil?
1116
+ # Legacy format: raw revision only.
1117
+ revision = content.strip
1118
+ end
1119
+
1120
+ { revision: revision, submodules: submodules }
1121
+ rescue StandardError
1122
+ { revision: nil, submodules: nil }
1123
+ end
1124
+
1125
+ def format_git_checkout_marker(revision, submodules:)
1126
+ "revision=#{revision}\nsubmodules=#{submodules ? 1 : 0}\n"
1127
+ end
1128
+
513
1129
  def compile_slots_for(worker_count)
514
- # Keep one worker lane available for non-compile tasks and cap native
515
- # compiles at two concurrent jobs.
516
- max_compile = [2, Platform.cpu_count].min
517
- available = [worker_count - 1, 1].max
518
- [max_compile, available].min
1130
+ # Keep compile parallelism conservative: at most 2 native builds.
1131
+ # Small pools stay single-lane; larger pools can run two builds.
1132
+ workers = worker_count.to_i
1133
+ return 1 if workers <= 6
1134
+
1135
+ 2
519
1136
  end
520
1137
 
521
1138
  def install_task_limits(worker_count, compile_slots)
522
1139
  # Leave headroom for compile and binstub lanes so link/download
523
1140
  # throughput cannot fully starve them.
524
1141
  io_cpu_limit = [worker_count - compile_slots - 1, 1].max
1142
+ # Keep download in-flight set bounded so fail-fast exits quickly on
1143
+ # auth/source errors instead of queueing a large burst.
1144
+ download_limit = [io_cpu_limit, 8].min
525
1145
  {
526
- download: io_cpu_limit,
1146
+ download: download_limit,
527
1147
  extract: io_cpu_limit,
528
1148
  link: io_cpu_limit,
529
1149
  build_ext: compile_slots,
@@ -531,6 +1151,12 @@ module Scint
531
1151
  }
532
1152
  end
533
1153
 
1154
+ def display_bundle_path(path)
1155
+ return path if path.start_with?("/", "./", "../")
1156
+
1157
+ "./#{path}"
1158
+ end
1159
+
534
1160
  # Enqueue dependency-aware install tasks so compile/binstub can run
535
1161
  # concurrently with link/download once prerequisites are satisfied.
536
1162
  def enqueue_install_dag(scheduler, plan, cache, bundle_path, progress = nil, compile_slots: 1)
@@ -545,8 +1171,8 @@ module Scint
545
1171
  when :skip
546
1172
  next
547
1173
  when :builtin
548
- install_builtin_gem(entry, bundle_path)
549
- next
1174
+ link_id = scheduler.enqueue(:link, entry.spec.name,
1175
+ -> { install_builtin_gem(entry, bundle_path) })
550
1176
  when :download
551
1177
  key = spec_key(entry.spec)
552
1178
  download_id = scheduler.enqueue(:download, entry.spec.name,
@@ -563,7 +1189,7 @@ module Scint
563
1189
  build_depends = (depends_on + dep_links).uniq
564
1190
 
565
1191
  extracted = extracted_path_for_entry(entry, cache)
566
- if Installer::ExtensionBuilder.buildable_source_dir?(extracted)
1192
+ if Installer::ExtensionBuilder.needs_build?(entry.spec, extracted)
567
1193
  build_id = scheduler.enqueue(:build_ext, entry.spec.name,
568
1194
  -> { build_extensions(entry, cache, bundle_path, progress, compile_slots: compile_slots) },
569
1195
  depends_on: build_depends)
@@ -626,7 +1252,7 @@ module Scint
626
1252
  end
627
1253
 
628
1254
  def spec_key(spec)
629
- "#{spec.name}-#{spec.version}-#{spec.platform}"
1255
+ SpecUtils.full_key(spec)
630
1256
  end
631
1257
 
632
1258
  def dependency_link_job_ids(spec, link_job_by_name)
@@ -649,7 +1275,7 @@ module Scint
649
1275
  enqueued = 0
650
1276
  entries.each do |entry|
651
1277
  extracted = extracted_path_for_entry(entry, cache)
652
- next unless Installer::ExtensionBuilder.buildable_source_dir?(extracted)
1278
+ next unless Installer::ExtensionBuilder.needs_build?(entry.spec, extracted)
653
1279
 
654
1280
  scheduler.enqueue(:build_ext, entry.spec.name,
655
1281
  -> { build_extensions(entry, cache, bundle_path, nil, compile_slots: compile_slots) })
@@ -661,10 +1287,75 @@ module Scint
661
1287
  def extracted_path_for_entry(entry, cache)
662
1288
  source_str = entry.spec.source.to_s
663
1289
  if source_str.start_with?("/") && Dir.exist?(source_str)
664
- source_str
1290
+ begin
1291
+ if path_source?(entry.spec.source)
1292
+ resolve_path_gem_subdir(source_str, entry.spec)
1293
+ else
1294
+ resolve_git_gem_subdir(source_str, entry.spec)
1295
+ end
1296
+ rescue InstallError
1297
+ source_str
1298
+ end
665
1299
  else
666
- entry.cached_path || cache.extracted_path(entry.spec)
1300
+ base = entry.cached_path || cache.extracted_path(entry.spec)
1301
+ if git_source?(entry.spec.source) && Dir.exist?(base)
1302
+ resolve_git_gem_subdir(base, entry.spec)
1303
+ elsif path_source?(entry.spec.source) && Dir.exist?(base)
1304
+ begin
1305
+ resolve_path_gem_subdir(base, entry.spec)
1306
+ rescue InstallError
1307
+ base
1308
+ end
1309
+ else
1310
+ base
1311
+ end
1312
+ end
1313
+ end
1314
+
1315
+ # For git monorepo sources, map gem name to its gemspec subdirectory.
1316
+ def resolve_git_gem_subdir(repo_root, spec)
1317
+ name = spec.name
1318
+ return repo_root if File.exist?(File.join(repo_root, "#{name}.gemspec"))
1319
+
1320
+ source = spec.source
1321
+ glob = source.respond_to?(:glob) ? source.glob : Source::Git::DEFAULT_GLOB
1322
+ Dir.glob(File.join(repo_root, glob)).each do |path|
1323
+ return File.dirname(path) if File.basename(path, ".gemspec") == name
1324
+ end
1325
+ # Compatibility fallback for monorepos whose gem layout does not match
1326
+ # the lockfile glob exactly.
1327
+ Dir.glob(File.join(repo_root, "**", "*.gemspec")).each do |path|
1328
+ return File.dirname(path) if File.basename(path, ".gemspec") == name
1329
+ end
1330
+
1331
+ source_uri = source.respond_to?(:uri) ? source.uri : source.to_s
1332
+ raise InstallError,
1333
+ "Git source #{source_uri} does not contain #{name}.gemspec (glob: #{glob.inspect}); lockfile source mapping may be stale"
1334
+ end
1335
+
1336
+ # For path monorepo sources, map gem name to its gemspec subdirectory.
1337
+ def resolve_path_gem_subdir(repo_root, spec)
1338
+ name = spec.name
1339
+ return repo_root if File.exist?(File.join(repo_root, "#{name}.gemspec"))
1340
+
1341
+ source = spec.source
1342
+ glob = source.respond_to?(:glob) ? source.glob : Source::Path::DEFAULT_GLOB
1343
+ Dir.glob(File.join(repo_root, glob)).each do |path|
1344
+ return File.dirname(path) if File.basename(path, ".gemspec") == name
1345
+ end
1346
+ Dir.glob(File.join(repo_root, "**", "*.gemspec")).each do |path|
1347
+ return File.dirname(path) if File.basename(path, ".gemspec") == name
667
1348
  end
1349
+
1350
+ source_uri =
1351
+ if source.respond_to?(:path)
1352
+ source.path
1353
+ elsif source.respond_to?(:uri)
1354
+ source.uri
1355
+ else
1356
+ source.to_s
1357
+ end
1358
+ raise InstallError, "Path source #{source_uri} does not contain #{name}.gemspec (glob: #{glob.inspect})"
668
1359
  end
669
1360
 
670
1361
  def link_gem_files(entry, cache, bundle_path)
@@ -680,14 +1371,13 @@ module Scint
680
1371
  from_cache: true,
681
1372
  )
682
1373
  Installer::Linker.link_files(prepared, bundle_path)
683
- Installer::Linker.link_files_to_ruby_dir(prepared, cache.install_ruby_dir)
684
1374
  # If this gem has a cached native build, materialize it during link.
685
1375
  # This lets reinstalling into a fresh .bundle skip build_ext entirely.
686
1376
  Installer::ExtensionBuilder.link_cached_build(prepared, bundle_path, cache)
687
1377
  end
688
1378
 
689
1379
  def build_extensions(entry, cache, bundle_path, progress = nil, compile_slots: 1)
690
- extracted = entry.cached_path || cache.extracted_path(entry.spec)
1380
+ extracted = extracted_path_for_entry(entry, cache)
691
1381
  gemspec = load_gemspec(extracted, entry.spec, cache)
692
1382
 
693
1383
  sync_build_env_dependencies(entry.spec, bundle_path, cache)
@@ -716,9 +1406,11 @@ module Scint
716
1406
  dep.name
717
1407
  end
718
1408
  end
1409
+ dep_names << "rake"
1410
+ dep_names.uniq!
719
1411
  return if dep_names.empty?
720
1412
 
721
- source_ruby_dir = File.join(bundle_path, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
1413
+ source_ruby_dir = Platform.ruby_install_dir(bundle_path)
722
1414
  target_ruby_dir = cache.install_ruby_dir
723
1415
 
724
1416
  dep_names.each do |name|
@@ -734,7 +1426,7 @@ module Scint
734
1426
  next unless Dir.exist?(source_gem_dir)
735
1427
 
736
1428
  target_gem_dir = File.join(target_ruby_dir, "gems", full_name)
737
- FS.hardlink_tree(source_gem_dir, target_gem_dir) unless Dir.exist?(target_gem_dir)
1429
+ FS.clone_tree(source_gem_dir, target_gem_dir) unless Dir.exist?(target_gem_dir)
738
1430
 
739
1431
  target_spec_dir = File.join(target_ruby_dir, "specifications")
740
1432
  target_spec_path = File.join(target_spec_dir, "#{full_name}.gemspec")
@@ -758,8 +1450,15 @@ module Scint
758
1450
  end
759
1451
 
760
1452
  def load_gemspec(extracted_path, spec, cache)
1453
+ cache_key = "#{cache.full_name(spec)}@#{extracted_path}"
1454
+ cached_value = @gemspec_cache_lock.synchronize { @gemspec_cache[cache_key] }
1455
+ return cached_value if cached_value
1456
+
761
1457
  cached = load_cached_gemspec(spec, cache, extracted_path)
762
- return cached if cached
1458
+ if cached
1459
+ @gemspec_cache_lock.synchronize { @gemspec_cache[cache_key] = cached }
1460
+ return cached
1461
+ end
763
1462
 
764
1463
  inbound = cache.inbound_path(spec)
765
1464
  return nil unless File.exist?(inbound)
@@ -767,12 +1466,38 @@ module Scint
767
1466
  begin
768
1467
  metadata = GemPkg::Package.new.read_metadata(inbound)
769
1468
  cache_gemspec(spec, metadata, cache)
1469
+ @gemspec_cache_lock.synchronize { @gemspec_cache[cache_key] = metadata }
770
1470
  metadata
771
1471
  rescue StandardError
772
1472
  nil
773
1473
  end
774
1474
  end
775
1475
 
1476
+ def bulk_prelink_gem_files(entries, cache, bundle_path)
1477
+ # Keep small installs simple; batching is for large warm-path runs.
1478
+ return if entries.length < 32
1479
+
1480
+ ruby_dir = File.join(bundle_path, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
1481
+ gems_dir = File.join(ruby_dir, "gems")
1482
+
1483
+ sources = entries.filter_map do |entry|
1484
+ next unless entry.action == :link || entry.action == :build_ext
1485
+
1486
+ extracted = cache.extracted_path(entry.spec)
1487
+ full_name = cache.full_name(entry.spec)
1488
+ next unless File.basename(extracted) == full_name
1489
+ next unless Dir.exist?(extracted)
1490
+ next if Dir.exist?(File.join(gems_dir, full_name))
1491
+
1492
+ extracted
1493
+ end
1494
+ return if sources.empty?
1495
+
1496
+ FS.clone_many_trees(sources, gems_dir)
1497
+ rescue StandardError => e
1498
+ $stderr.puts("bulk prelink warning: #{e.message}") if ENV["SCINT_DEBUG"]
1499
+ end
1500
+
776
1501
  def load_cached_gemspec(spec, cache, extracted_path)
777
1502
  path = cache.spec_cache_path(spec)
778
1503
  return nil unless File.exist?(path)
@@ -832,79 +1557,386 @@ module Scint
832
1557
 
833
1558
  # --- Lockfile + runtime config ---
834
1559
 
835
- def write_lockfile(resolved, gemfile)
836
- sources = []
1560
+ def write_lockfile(resolved, gemfile, lockfile = nil)
1561
+ specs, sources, preserved_layout = build_lockfile_specs_and_sources(resolved, gemfile, lockfile)
1562
+
1563
+ lockfile_data = Lockfile::LockfileData.new(
1564
+ specs: specs,
1565
+ dependencies: build_lockfile_dependencies(gemfile, lockfile),
1566
+ platforms: preserved_layout && lockfile ? Array(lockfile.platforms) : build_lockfile_platforms(specs, lockfile),
1567
+ sources: sources,
1568
+ bundler_version: lockfile&.bundler_version || Scint::VERSION,
1569
+ ruby_version: lockfile&.ruby_version || gemfile.ruby_version,
1570
+ checksums: preserved_layout && lockfile ? lockfile.checksums : build_lockfile_checksums(specs, lockfile),
1571
+ )
1572
+
1573
+ content = Lockfile::Writer.write(lockfile_data)
1574
+ FS.atomic_write("Gemfile.lock", content)
1575
+ end
1576
+
1577
+ def build_lockfile_specs_and_sources(resolved, gemfile, lockfile)
1578
+ resolved_for_lockfile = filter_lockfile_specs(resolved)
1579
+
1580
+ if preserve_existing_lockfile_specs?(resolved_for_lockfile, lockfile)
1581
+ specs = Array(lockfile.specs).map { |spec| normalize_lockfile_spec(spec) }
1582
+ sources = uniq_sources(Array(lockfile.sources))
1583
+ return [specs, sources, true]
1584
+ end
1585
+
1586
+ dependency_sources = dependency_sources_from_gemfile(gemfile, lockfile)
1587
+ existing_sources = Array(lockfile&.sources)
1588
+ candidate_sources = uniq_sources(existing_sources + dependency_sources.values)
1589
+
1590
+ rubygems_uris = collect_lockfile_rubygems_uris(gemfile)
1591
+ if rubygems_uris.empty? && candidate_sources.none? { |src| src.is_a?(Source::Rubygems) }
1592
+ rubygems_uris << "https://rubygems.org"
1593
+ end
1594
+ rubygems_uris.each do |uri|
1595
+ source = find_matching_rubygems_source(candidate_sources, uri)
1596
+ candidate_sources << Source::Rubygems.new(remotes: [uri]) unless source
1597
+ end
1598
+ candidate_sources = uniq_sources(candidate_sources)
1599
+
1600
+ lock_source_by_full, lock_source_by_name_version = lockfile_sources_by_spec_key(lockfile)
1601
+ default_rubygems_source = candidate_sources.find { |src| src.is_a?(Source::Rubygems) }
1602
+
1603
+ specs = resolved_for_lockfile.map do |spec|
1604
+ normalized = normalize_resolved_spec(spec)
1605
+ source = source_for_spec(
1606
+ normalized,
1607
+ dependency_sources: dependency_sources,
1608
+ candidate_sources: candidate_sources,
1609
+ lock_source_by_full: lock_source_by_full,
1610
+ lock_source_by_name_version: lock_source_by_name_version,
1611
+ fallback: default_rubygems_source,
1612
+ )
1613
+ normalized.merge(source: source)
1614
+ end
1615
+
1616
+ sources = uniq_sources(specs.map { |spec| spec[:source] }.compact)
1617
+ if sources.empty?
1618
+ fallback = default_rubygems_source || Source::Rubygems.new(remotes: ["https://rubygems.org"])
1619
+ sources = [fallback]
1620
+ specs.each { |spec| spec[:source] = fallback }
1621
+ end
1622
+
1623
+ [specs, sources, false]
1624
+ end
1625
+
1626
+ def filter_lockfile_specs(specs)
1627
+ specs.reject do |spec|
1628
+ name = spec.is_a?(Hash) ? spec[:name].to_s : spec.name.to_s
1629
+ name == "scint"
1630
+ end
1631
+ end
1632
+
1633
+ def preserve_existing_lockfile_specs?(resolved, lockfile)
1634
+ return false unless lockfile && lockfile.respond_to?(:specs)
1635
+
1636
+ wanted = resolved.map { |spec| [spec.name.to_s, spec.version.to_s] }.uniq
1637
+ return false if wanted.empty?
1638
+
1639
+ available = Set.new
1640
+ Array(lockfile.specs).each do |spec|
1641
+ available << [spec[:name].to_s, spec[:version].to_s]
1642
+ end
1643
+
1644
+ wanted.all? { |tuple| available.include?(tuple) }
1645
+ end
1646
+
1647
+ def normalize_lockfile_spec(spec)
1648
+ if spec.is_a?(Hash)
1649
+ {
1650
+ name: spec[:name],
1651
+ version: spec[:version],
1652
+ platform: spec[:platform] || "ruby",
1653
+ dependencies: spec[:dependencies] || [],
1654
+ source: spec[:source],
1655
+ checksum: spec[:checksum],
1656
+ }
1657
+ else
1658
+ {
1659
+ name: spec.name,
1660
+ version: spec.version,
1661
+ platform: spec.platform || "ruby",
1662
+ dependencies: spec.dependencies || [],
1663
+ source: spec.source,
1664
+ checksum: spec.respond_to?(:checksum) ? spec.checksum : nil,
1665
+ }
1666
+ end
1667
+ end
1668
+
1669
+ def normalize_resolved_spec(spec)
1670
+ if spec.is_a?(Hash)
1671
+ {
1672
+ name: spec[:name],
1673
+ version: spec[:version],
1674
+ platform: spec[:platform] || "ruby",
1675
+ dependencies: spec[:dependencies] || [],
1676
+ source: spec[:source],
1677
+ checksum: spec[:checksum],
1678
+ }
1679
+ else
1680
+ {
1681
+ name: spec.name,
1682
+ version: spec.version,
1683
+ platform: spec.platform || "ruby",
1684
+ dependencies: spec.dependencies || [],
1685
+ source: spec.source,
1686
+ checksum: spec.respond_to?(:checksum) ? spec.checksum : nil,
1687
+ }
1688
+ end
1689
+ end
1690
+
1691
+ def collect_lockfile_rubygems_uris(gemfile)
1692
+ uris = gemfile.sources
1693
+ .select { |src| src[:type] == :rubygems && src[:uri] }
1694
+ .map { |src| src[:uri].to_s }
1695
+
1696
+ gemfile.dependencies.each do |dep|
1697
+ inline = dep.source_options[:source]
1698
+ uris << inline.to_s if inline
1699
+ end
1700
+
1701
+ uris.uniq
1702
+ end
1703
+
1704
+ def dependency_sources_from_gemfile(gemfile, lockfile)
1705
+ existing_sources = Array(lockfile&.sources)
1706
+ out = {}
837
1707
 
838
- # Build source objects for path and git gems
839
1708
  gemfile.dependencies.each do |dep|
840
1709
  opts = dep.source_options
841
- if opts[:path]
842
- sources << Source::Path.new(path: opts[:path], name: dep.name)
843
- elsif opts[:git]
844
- sources << Source::Git.new(
845
- uri: opts[:git],
846
- branch: opts[:branch],
847
- tag: opts[:tag],
848
- ref: opts[:ref],
849
- )
1710
+
1711
+ source =
1712
+ if opts[:path]
1713
+ find_matching_path_source(existing_sources, opts[:path]) ||
1714
+ Source::Path.new(path: opts[:path], name: dep.name)
1715
+ elsif opts[:git]
1716
+ matched = find_matching_git_source(existing_sources, opts)
1717
+ Source::Git.new(
1718
+ uri: opts[:git],
1719
+ revision: matched&.revision,
1720
+ ref: opts[:ref] || matched&.ref,
1721
+ branch: opts[:branch] || matched&.branch,
1722
+ tag: opts[:tag] || matched&.tag,
1723
+ submodules: opts.fetch(:submodules, matched&.submodules),
1724
+ glob: matched&.glob,
1725
+ name: dep.name,
1726
+ )
1727
+ elsif opts[:source]
1728
+ find_matching_rubygems_source(existing_sources, opts[:source]) ||
1729
+ Source::Rubygems.new(remotes: [opts[:source]])
1730
+ end
1731
+
1732
+ out[dep.name] = source if source
1733
+ end
1734
+
1735
+ out
1736
+ end
1737
+
1738
+ def lockfile_sources_by_spec_key(lockfile)
1739
+ by_full = {}
1740
+ by_name_version = {}
1741
+
1742
+ Array(lockfile&.specs).each do |spec|
1743
+ source = spec[:source]
1744
+ next unless source
1745
+
1746
+ name = spec[:name].to_s
1747
+ version = spec[:version].to_s
1748
+ platform = (spec[:platform] || "ruby").to_s
1749
+
1750
+ by_full[[name, version, platform]] = source
1751
+ by_name_version[[name, version]] ||= source
1752
+ end
1753
+
1754
+ [by_full, by_name_version]
1755
+ end
1756
+
1757
+ def source_for_spec(spec, dependency_sources:, candidate_sources:, lock_source_by_full:, lock_source_by_name_version:, fallback:)
1758
+ key_full = [spec[:name].to_s, spec[:version].to_s, spec[:platform].to_s]
1759
+ locked_source = lock_source_by_full[key_full] || lock_source_by_name_version[key_full[0, 2]]
1760
+ return locked_source if locked_source
1761
+
1762
+ dep_source = dependency_sources[spec[:name].to_s]
1763
+ return dep_source if dep_source
1764
+
1765
+ spec_source = spec[:source]
1766
+ source = find_matching_source(candidate_sources, spec_source)
1767
+ return source if source
1768
+
1769
+ spec_source = spec_source.to_s
1770
+ if git_source?(spec_source)
1771
+ source = Source::Git.new(uri: spec_source, name: spec[:name])
1772
+ candidate_sources << source
1773
+ return source
1774
+ elsif spec_source.start_with?("/") || spec_source.start_with?(".")
1775
+ source = Source::Path.new(path: spec_source, name: spec[:name])
1776
+ candidate_sources << source
1777
+ return source
1778
+ end
1779
+
1780
+ if rubygems_source_uri?(spec_source.to_s)
1781
+ source = Source::Rubygems.new(remotes: [spec_source.to_s])
1782
+ candidate_sources << source
1783
+ return source
1784
+ end
1785
+
1786
+ fallback
1787
+ end
1788
+
1789
+ def find_matching_source(sources, source_ref)
1790
+ return nil if source_ref.nil?
1791
+
1792
+ sources.find do |source|
1793
+ source_matches?(source, source_ref)
1794
+ end
1795
+ end
1796
+
1797
+ def source_matches?(source, source_ref)
1798
+ return true if source.equal?(source_ref)
1799
+ return true if source == source_ref
1800
+
1801
+ source_key = normalize_source_key(source_ref)
1802
+ return false unless source_key
1803
+
1804
+ if source.is_a?(Source::Rubygems)
1805
+ source.remotes.any? { |remote| normalize_source_key(remote) == source_key }
1806
+ elsif source.respond_to?(:uri)
1807
+ normalize_source_key(source.uri) == source_key
1808
+ else
1809
+ normalize_source_key(source) == source_key
1810
+ end
1811
+ end
1812
+
1813
+ def normalize_source_key(source_ref)
1814
+ return nil if source_ref.nil?
1815
+
1816
+ raw =
1817
+ if source_ref.respond_to?(:uri)
1818
+ source_ref.uri.to_s
1819
+ elsif source_ref.respond_to?(:path)
1820
+ source_ref.path.to_s
1821
+ else
1822
+ source_ref.to_s
850
1823
  end
1824
+ return nil if raw.empty?
1825
+
1826
+ if raw.match?(%r{\Ahttps?://}i)
1827
+ raw = raw.sub(%r{\Ahttps?://}i, "")
1828
+ raw = raw.sub(%r{\.git/?\z}i, "")
1829
+ raw.chomp("/").downcase
1830
+ elsif raw.start_with?("/") || raw.start_with?(".")
1831
+ File.expand_path(raw)
1832
+ else
1833
+ raw.sub(%r{\.git/?\z}i, "").chomp("/").downcase
851
1834
  end
1835
+ end
852
1836
 
853
- # Build rubygems sources -- collect all unique URIs
854
- rubygems_uris = gemfile.sources
855
- .select { |s| s[:type] == :rubygems }
856
- .map { |s| s[:uri] }
857
- .uniq
1837
+ def find_matching_rubygems_source(sources, uri)
1838
+ sources.find do |source|
1839
+ source.is_a?(Source::Rubygems) && source.remotes.any? { |remote| source_matches?(remote, uri) }
1840
+ end
1841
+ end
858
1842
 
859
- # Group URIs that share specs into one Source::Rubygems each.
860
- # The default source gets all remotes that aren't a separate scoped source.
861
- scoped_uris = Set.new
862
- gemfile.dependencies.each do |dep|
863
- src = dep.source_options[:source]
864
- scoped_uris << src if src
1843
+ def find_matching_path_source(sources, path)
1844
+ sources.find { |source| source.is_a?(Source::Path) && source_matches?(source, path) }
1845
+ end
1846
+
1847
+ def find_matching_git_source(sources, opts)
1848
+ candidates = sources.select { |source| source.is_a?(Source::Git) && source_matches?(source, opts[:git]) }
1849
+ return nil if candidates.empty?
1850
+
1851
+ candidates.find { |source| git_source_options_match?(source, opts) } || candidates.first
1852
+ end
1853
+
1854
+ def git_source_options_match?(source, opts)
1855
+ return false if opts[:branch] && source.branch.to_s != opts[:branch].to_s
1856
+ return false if opts[:tag] && source.tag.to_s != opts[:tag].to_s
1857
+ return false if opts[:ref] && source.ref.to_s != opts[:ref].to_s
1858
+
1859
+ true
1860
+ end
1861
+
1862
+ def uniq_sources(sources)
1863
+ out = []
1864
+ sources.each do |source|
1865
+ next unless source
1866
+ out << source unless out.any? { |existing| existing.eql?(source) }
865
1867
  end
1868
+ out
1869
+ end
866
1870
 
867
- # Each scoped URI gets its own source object
868
- scoped_uris.each do |uri|
869
- sources << Source::Rubygems.new(remotes: [uri])
1871
+ def build_lockfile_dependencies(gemfile, lockfile)
1872
+ locked = lockfile&.dependencies || {}
1873
+ gemfile.dependencies
1874
+ .select { |dep| lockfile_dependency_direct?(dep) }
1875
+ .map do |dep|
1876
+ locked_dep = locked[dep.name]
1877
+ {
1878
+ name: dep.name,
1879
+ version_reqs: dep.version_reqs,
1880
+ pinned: !!(locked_dep && locked_dep[:pinned]),
1881
+ }
1882
+ end
1883
+ end
1884
+
1885
+ def lockfile_dependency_direct?(dep)
1886
+ opts = dep.source_options || {}
1887
+ return true unless opts[:gemspec_generated]
1888
+
1889
+ opts[:gemspec_primary] != false
1890
+ end
1891
+
1892
+ def build_lockfile_platforms(specs, lockfile)
1893
+ platforms = Set.new(Array(lockfile&.platforms))
1894
+ specs.each do |spec|
1895
+ platform = spec[:platform] || "ruby"
1896
+ platforms << platform
870
1897
  end
1898
+ platforms << "ruby"
1899
+ platforms.to_a
1900
+ end
871
1901
 
872
- # Default rubygems source with remaining remotes
873
- default_remotes = rubygems_uris.reject { |u| scoped_uris.include?(u) }
874
- default_remotes = ["https://rubygems.org"] if default_remotes.empty?
875
- sources << Source::Rubygems.new(remotes: default_remotes)
1902
+ def build_lockfile_checksums(specs, lockfile)
1903
+ existing = lockfile&.checksums
1904
+ checksums = {}
1905
+
1906
+ specs.each do |spec|
1907
+ key = lockfile_spec_checksum_key(spec)
1908
+ checksum = spec[:checksum]
1909
+ if checksum && !Array(checksum).empty?
1910
+ checksums[key] = Array(checksum)
1911
+ elsif existing&.key?(key)
1912
+ checksums[key] = Array(existing[key])
1913
+ end
1914
+ end
876
1915
 
877
- lockfile_data = Lockfile::LockfileData.new(
878
- specs: resolved,
879
- dependencies: gemfile.dependencies.map { |d| { name: d.name, version_reqs: d.version_reqs } },
880
- platforms: [Platform.local_platform.to_s, "ruby"].uniq,
881
- sources: sources,
882
- bundler_version: Scint::VERSION,
883
- ruby_version: nil,
884
- checksums: nil,
885
- )
1916
+ return nil if checksums.empty?
886
1917
 
887
- content = Lockfile::Writer.write(lockfile_data)
888
- FS.atomic_write("Gemfile.lock", content)
1918
+ checksums
1919
+ end
1920
+
1921
+ def lockfile_spec_checksum_key(spec)
1922
+ SpecUtils.full_name_for(spec[:name], spec[:version], spec[:platform] || "ruby")
889
1923
  end
890
1924
 
891
1925
  def write_runtime_config(resolved, bundle_path)
892
- ruby_dir = File.join(bundle_path, "ruby",
893
- RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
1926
+ ruby_dir = Platform.ruby_install_dir(bundle_path)
894
1927
 
895
1928
  data = {}
896
1929
  resolved.each do |spec|
897
- full = spec_full_name(spec)
1930
+ full = SpecUtils.full_name(spec)
898
1931
  gem_dir = File.join(ruby_dir, "gems", full)
899
1932
  spec_file = File.join(ruby_dir, "specifications", "#{full}.gemspec")
900
1933
  require_paths = read_require_paths(spec_file)
901
1934
  load_paths = require_paths
902
- .map { |rp| File.join(gem_dir, rp) }
1935
+ .map { |rp| expand_require_path(gem_dir, rp) }
903
1936
  .select { |path| Dir.exist?(path) }
904
1937
 
905
1938
  default_lib = File.join(gem_dir, "lib")
906
1939
  load_paths << default_lib if load_paths.empty? && Dir.exist?(default_lib)
907
- load_paths.concat(detect_nested_lib_paths(gem_dir))
908
1940
  load_paths.uniq!
909
1941
 
910
1942
  # Add ext load path if extensions exist
@@ -912,6 +1944,12 @@ module Scint
912
1944
  Platform.gem_arch, Platform.extension_api_version, full)
913
1945
  load_paths << ext_dir if Dir.exist?(ext_dir)
914
1946
 
1947
+ if load_paths.empty?
1948
+ source_paths = runtime_source_load_paths(spec)
1949
+ load_paths.concat(source_paths)
1950
+ load_paths.uniq!
1951
+ end
1952
+
915
1953
  data[spec.name] = {
916
1954
  version: spec.version.to_s,
917
1955
  load_paths: load_paths,
@@ -932,26 +1970,40 @@ module Scint
932
1970
  ["lib"]
933
1971
  end
934
1972
 
935
- def detect_nested_lib_paths(gem_dir)
936
- lib_dir = File.join(gem_dir, "lib")
937
- return [] unless Dir.exist?(lib_dir)
1973
+ def expand_require_path(gem_dir, require_path)
1974
+ value = require_path.to_s
1975
+ return value if Pathname.new(value).absolute?
938
1976
 
939
- children = Dir.children(lib_dir)
940
- top_level_rb = children.any? do |entry|
941
- path = File.join(lib_dir, entry)
942
- File.file?(path) && entry.end_with?(".rb")
1977
+ File.join(gem_dir, value)
1978
+ rescue StandardError
1979
+ File.join(gem_dir, require_path.to_s)
1980
+ end
1981
+
1982
+ def runtime_source_load_paths(spec)
1983
+ source_root = spec.source.to_s
1984
+ return [] unless source_root.start_with?("/") && Dir.exist?(source_root)
1985
+
1986
+ source_dir = begin
1987
+ resolve_git_gem_subdir(source_root, spec)
1988
+ rescue InstallError
1989
+ source_root
943
1990
  end
944
- return [] if top_level_rb
945
1991
 
946
- children
947
- .map { |entry| File.join(lib_dir, entry) }
948
- .select { |path| File.directory?(path) }
1992
+ gemspec_file = File.join(source_dir, "#{spec.name}.gemspec")
1993
+ require_paths = read_require_paths(gemspec_file)
1994
+ paths = require_paths
1995
+ .map { |rp| expand_require_path(source_dir, rp) }
1996
+ .select { |path| Dir.exist?(path) }
1997
+
1998
+ default_lib = File.join(source_dir, "lib")
1999
+ paths << default_lib if paths.empty? && Dir.exist?(default_lib)
2000
+ paths.uniq
2001
+ rescue StandardError
2002
+ []
949
2003
  end
950
2004
 
951
2005
  def spec_full_name(spec)
952
- base = "#{spec.name}-#{spec.version}"
953
- plat = spec.respond_to?(:platform) ? spec.platform : nil
954
- (plat.nil? || plat.to_s == "ruby" || plat.to_s.empty?) ? base : "#{base}-#{plat}"
2006
+ SpecUtils.full_name(spec)
955
2007
  end
956
2008
 
957
2009
  def elapsed_ms_since(start_time)
@@ -960,7 +2012,7 @@ module Scint
960
2012
  end
961
2013
 
962
2014
  def force_purge_artifacts(resolved, bundle_path, cache)
963
- ruby_dir = File.join(bundle_path, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
2015
+ ruby_dir = Platform.ruby_install_dir(bundle_path)
964
2016
  ext_root = File.join(ruby_dir, "extensions", Platform.gem_arch, Platform.extension_api_version)
965
2017
 
966
2018
  resolved.each do |spec|
@@ -990,6 +2042,34 @@ module Scint
990
2042
  "#{(elapsed_ms / 1000.0).round(2)}s"
991
2043
  end
992
2044
 
2045
+ def format_run_footer(elapsed_ms, worker_count)
2046
+ workers = worker_count.to_i
2047
+ noun = workers == 1 ? "worker" : "workers"
2048
+ "#{format_elapsed(elapsed_ms)}, #{workers} #{noun} used"
2049
+ end
2050
+
2051
+ def emit_network_error_details(error)
2052
+ return unless error.is_a?(NetworkError)
2053
+
2054
+ headers = error.response_headers
2055
+ body = error.response_body.to_s
2056
+ return if (headers.nil? || headers.empty?) && body.empty?
2057
+
2058
+ if headers && !headers.empty?
2059
+ $stderr.puts " headers:"
2060
+ headers.sort.each do |key, value|
2061
+ $stderr.puts " #{key}: #{value}"
2062
+ end
2063
+ end
2064
+
2065
+ return if body.empty?
2066
+
2067
+ $stderr.puts " body:"
2068
+ body.each_line do |line|
2069
+ $stderr.puts " #{line.rstrip}"
2070
+ end
2071
+ end
2072
+
993
2073
  def warn_missing_bundle_gitignore_entry
994
2074
  path = ".gitignore"
995
2075
  return unless File.file?(path)