scint 0.6.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
 
@@ -117,7 +124,14 @@ module Scint
117
124
  # Scale up for download/install phase based on actual work count
118
125
  scheduler.scale_workers(to_install.size)
119
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
+
120
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)
121
135
  elapsed_ms = elapsed_ms_since(start_time)
122
136
  worker_count = scheduler.stats[:workers]
123
137
  warn_missing_bundle_gitignore_entry
@@ -148,7 +162,9 @@ module Scint
148
162
  if errors.any?
149
163
  $stderr.puts "#{RED}Some gems failed to install:#{RESET}"
150
164
  errors.each do |err|
151
- $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)
152
168
  end
153
169
  elsif stats[:failed] > 0
154
170
  $stderr.puts "#{YELLOW}Warning: #{stats[:failed]} jobs failed but no error details captured#{RESET}"
@@ -168,14 +184,18 @@ module Scint
168
184
  1
169
185
  else
170
186
  # 10. Write lockfile + runtime config only for successful installs
171
- write_lockfile(resolved, gemfile)
187
+ write_lockfile(resolved, gemfile, lockfile)
172
188
  write_runtime_config(resolved, bundle_path)
173
189
  warn_missing_bundle_gitignore_entry
174
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}"
175
191
  0
176
192
  end
177
193
  ensure
178
- scheduler.shutdown
194
+ begin
195
+ scheduler.shutdown
196
+ ensure
197
+ close_download_pool
198
+ end
179
199
  end
180
200
  end
181
201
 
@@ -207,7 +227,7 @@ module Scint
207
227
  def dedupe_resolved_specs(resolved)
208
228
  seen = {}
209
229
  resolved.each do |spec|
210
- key = "#{spec.name}-#{spec.version}-#{spec.platform}"
230
+ key = SpecUtils.full_key(spec)
211
231
  seen[key] ||= spec
212
232
  end
213
233
  seen.values
@@ -217,8 +237,8 @@ module Scint
217
237
  # No download needed — we know exactly where we are.
218
238
  def install_builtin_gem(entry, bundle_path)
219
239
  spec = entry.spec
220
- ruby_dir = File.join(bundle_path, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
221
- full_name = spec_full_name(spec)
240
+ ruby_dir = Platform.ruby_install_dir(bundle_path)
241
+ full_name = SpecUtils.full_name(spec)
222
242
  scint_root = File.expand_path("../../..", __FILE__)
223
243
 
224
244
  # Copy gem files into gems/scint-x.y.z/lib/
@@ -270,7 +290,10 @@ module Scint
270
290
 
271
291
  def resolve(gemfile, lockfile, cache)
272
292
  # If lockfile is up-to-date, use its specs directly
273
- 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)
274
297
  return lockfile_to_resolved(lockfile)
275
298
  end
276
299
 
@@ -305,6 +328,7 @@ module Scint
305
328
  # Build path_gems: gem_name => { version:, dependencies:, source: }
306
329
  # for gems with path: or git: sources (skip compact index for these)
307
330
  path_gems = {}
331
+ git_source_metadata_cache = {}
308
332
  gemfile.dependencies.each do |dep|
309
333
  opts = dep.source_options
310
334
  next unless opts[:path] || opts[:git]
@@ -314,19 +338,57 @@ module Scint
314
338
 
315
339
  # Try to read version and deps from gemspec if it's a path gem
316
340
  if opts[:path]
317
- gemspec = find_gemspec(opts[:path], dep.name)
341
+ gemspec = find_gemspec(opts[:path], dep.name, glob: opts[:glob])
318
342
  if gemspec
319
343
  version = gemspec.version.to_s
320
344
  deps = gemspec.dependencies
321
345
  .select { |d| d.type == :runtime }
322
- .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
323
350
  end
324
351
  end
325
352
 
326
- # For git gems, try lockfile for version
327
- if opts[:git] && lockfile
328
- locked_spec = lockfile.specs.find { |s| s[:name] == dep.name }
329
- 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
330
392
  end
331
393
 
332
394
  source_desc = opts[:path] || opts[:git] || "local"
@@ -353,14 +415,16 @@ module Scint
353
415
  resolver.resolve
354
416
  end
355
417
 
356
- def find_gemspec(path, gem_name)
418
+ def find_gemspec(path, gem_name, glob: nil)
357
419
  return nil unless Dir.exist?(path)
358
420
 
421
+ glob_pattern = glob || Source::Path::DEFAULT_GLOB
359
422
  # Look for exact match first, then any gemspec
360
423
  candidates = [
361
424
  File.join(path, "#{gem_name}.gemspec"),
425
+ *Dir.glob(File.join(path, glob_pattern)),
362
426
  *Dir.glob(File.join(path, "*.gemspec")),
363
- ]
427
+ ].uniq
364
428
 
365
429
  candidates.each do |gs|
366
430
  next unless File.exist?(gs)
@@ -374,11 +438,135 @@ module Scint
374
438
  nil
375
439
  end
376
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
+
377
501
  def lockfile_current?(gemfile, lockfile)
378
502
  return false unless lockfile
379
503
 
380
504
  locked_names = Set.new(lockfile.specs.map { |s| s[:name] })
381
- 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
382
570
  end
383
571
 
384
572
  def lockfile_to_resolved(lockfile)
@@ -414,6 +602,108 @@ module Scint
414
602
  apply_locked_platform_preferences(resolved)
415
603
  end
416
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
+
417
707
  # Preference: exact platform match > compatible match > ruby > first.
418
708
  def pick_best_platform_spec(specs, local_plat)
419
709
  return specs.first if specs.size == 1
@@ -451,7 +741,7 @@ module Scint
451
741
  return resolved_specs if preferred.empty?
452
742
 
453
743
  resolved_specs.each do |spec|
454
- key = "#{spec.name}-#{spec.version}"
744
+ key = SpecUtils.full_name_for(spec.name, spec.version)
455
745
  platform = preferred[key]
456
746
  next if platform.nil? || platform.empty?
457
747
 
@@ -478,7 +768,7 @@ module Scint
478
768
  preferred = preferred.to_s
479
769
  next if preferred.empty? || preferred == spec.platform.to_s
480
770
 
481
- out["#{spec.name}-#{spec.version}"] = preferred
771
+ out[SpecUtils.full_name_for(spec.name, spec.version)] = preferred
482
772
  end
483
773
  rescue StandardError
484
774
  next
@@ -496,7 +786,7 @@ module Scint
496
786
  spec = entry.spec
497
787
  source = spec.source
498
788
  if git_source?(source)
499
- prepare_git_source(entry, cache)
789
+ prepare_git_checkout(spec, cache, fetch: false)
500
790
  return
501
791
  end
502
792
  source_uri = source.to_s
@@ -504,7 +794,7 @@ module Scint
504
794
  # Path gems are not downloaded from a remote
505
795
  return if source_uri.start_with?("/") || !source_uri.start_with?("http")
506
796
 
507
- full_name = spec_full_name(spec)
797
+ full_name = SpecUtils.full_name(spec)
508
798
  gem_filename = "#{full_name}.gem"
509
799
  source_uri = source_uri.chomp("/")
510
800
  download_uri = "#{source_uri}/gems/#{gem_filename}"
@@ -513,9 +803,20 @@ module Scint
513
803
  FS.mkdir_p(File.dirname(dest_path))
514
804
 
515
805
  unless File.exist?(dest_path)
516
- pool = Downloader::Pool.new(size: 1, credentials: @credentials)
517
- pool.download(download_uri, dest_path)
518
- 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
519
820
  end
520
821
  end
521
822
 
@@ -523,8 +824,12 @@ module Scint
523
824
  spec = entry.spec
524
825
  source_uri = spec.source.to_s
525
826
 
526
- # Git/path gems are already materialized by checkout or local path.
527
- 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
528
833
  return if source_uri.start_with?("/") || !source_uri.start_with?("http")
529
834
 
530
835
  extracted = cache.extracted_path(spec)
@@ -546,51 +851,132 @@ module Scint
546
851
  source_str.end_with?(".git") || source_str.include?(".git/")
547
852
  end
548
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
+
549
870
  def prepare_git_source(entry, cache)
871
+ # Legacy helper used by tests/callers that expect git download+extract
872
+ # in a single step.
550
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)
551
879
  source = spec.source
552
880
  uri, revision = git_source_ref(source)
881
+ submodules = git_source_submodules?(source)
553
882
 
554
883
  bare_repo = cache.git_path(uri)
555
884
 
556
885
  # Serialize all git operations per bare repo — git uses index.lock
557
886
  # and can't handle concurrent checkouts from the same repo.
558
887
  git_mutex_for(bare_repo).synchronize do
559
- clone_git_repo(uri, bare_repo) unless Dir.exist?(bare_repo)
560
- fetch_git_repo(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
561
894
 
562
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
563
908
 
564
- extracted = cache.extracted_path(spec)
565
- marker = git_checkout_marker_path(extracted)
566
- if Dir.exist?(extracted) && File.exist?(marker)
567
- return if File.read(marker).strip == resolved_revision
568
- end
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
569
921
 
570
- tmp = "#{extracted}.#{Process.pid}.#{Thread.current.object_id}.tmp"
571
- begin
572
- FileUtils.rm_rf(tmp)
573
- FileUtils.mkdir_p(tmp)
574
-
575
- _out, err, status = git_capture3(
576
- "--git-dir", bare_repo,
577
- "--work-tree", tmp,
578
- "checkout",
579
- "-f",
922
+ tmp = "#{extracted}.#{Process.pid}.#{Thread.current.object_id}.tmp"
923
+ begin
924
+ FileUtils.rm_rf(tmp)
925
+ FS.clone_tree(gem_root, tmp)
926
+
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
934
+
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,
580
961
  resolved_revision,
581
- "--",
582
- ".",
962
+ spec,
963
+ uri,
964
+ )
965
+ else
966
+ checkout_git_tree(
967
+ bare_repo,
968
+ tmp,
969
+ resolved_revision,
970
+ spec,
971
+ uri,
583
972
  )
584
- unless status.success?
585
- raise InstallError, "Git checkout failed for #{spec.name} (#{uri}@#{resolved_revision}): #{err.to_s.strip}"
586
- end
587
-
588
- FileUtils.rm_rf(extracted)
589
- FS.atomic_move(tmp, extracted)
590
- FS.atomic_write(marker, "#{resolved_revision}\n")
591
- ensure
592
- FileUtils.rm_rf(tmp) if tmp && File.exist?(tmp)
593
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)
594
980
  end
595
981
  end
596
982
 
@@ -603,6 +989,68 @@ module Scint
603
989
  [source.to_s, "HEAD"]
604
990
  end
605
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
+
606
1054
  def git_mutex_for(repo_path)
607
1055
  @git_mutexes_lock ||= Thread::Mutex.new
608
1056
  @git_mutexes_lock.synchronize do
@@ -649,19 +1097,53 @@ module Scint
649
1097
  "#{dir}.scint_git_revision"
650
1098
  end
651
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
+
652
1129
  def compile_slots_for(worker_count)
653
- # Keep native builds serialized to avoid build-env races and reduce
654
- # memory/CPU spikes from concurrent extension compiles.
655
- _ = worker_count
656
- 1
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
657
1136
  end
658
1137
 
659
1138
  def install_task_limits(worker_count, compile_slots)
660
1139
  # Leave headroom for compile and binstub lanes so link/download
661
1140
  # throughput cannot fully starve them.
662
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
663
1145
  {
664
- download: io_cpu_limit,
1146
+ download: download_limit,
665
1147
  extract: io_cpu_limit,
666
1148
  link: io_cpu_limit,
667
1149
  build_ext: compile_slots,
@@ -770,7 +1252,7 @@ module Scint
770
1252
  end
771
1253
 
772
1254
  def spec_key(spec)
773
- "#{spec.name}-#{spec.version}-#{spec.platform}"
1255
+ SpecUtils.full_key(spec)
774
1256
  end
775
1257
 
776
1258
  def dependency_link_job_ids(spec, link_job_by_name)
@@ -805,11 +1287,25 @@ module Scint
805
1287
  def extracted_path_for_entry(entry, cache)
806
1288
  source_str = entry.spec.source.to_s
807
1289
  if source_str.start_with?("/") && Dir.exist?(source_str)
808
- 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
809
1299
  else
810
1300
  base = entry.cached_path || cache.extracted_path(entry.spec)
811
1301
  if git_source?(entry.spec.source) && Dir.exist?(base)
812
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
813
1309
  else
814
1310
  base
815
1311
  end
@@ -826,8 +1322,40 @@ module Scint
826
1322
  Dir.glob(File.join(repo_root, glob)).each do |path|
827
1323
  return File.dirname(path) if File.basename(path, ".gemspec") == name
828
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
829
1330
 
830
- repo_root
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
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})"
831
1359
  end
832
1360
 
833
1361
  def link_gem_files(entry, cache, bundle_path)
@@ -882,7 +1410,7 @@ module Scint
882
1410
  dep_names.uniq!
883
1411
  return if dep_names.empty?
884
1412
 
885
- 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)
886
1414
  target_ruby_dir = cache.install_ruby_dir
887
1415
 
888
1416
  dep_names.each do |name|
@@ -922,8 +1450,15 @@ module Scint
922
1450
  end
923
1451
 
924
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
+
925
1457
  cached = load_cached_gemspec(spec, cache, extracted_path)
926
- return cached if cached
1458
+ if cached
1459
+ @gemspec_cache_lock.synchronize { @gemspec_cache[cache_key] = cached }
1460
+ return cached
1461
+ end
927
1462
 
928
1463
  inbound = cache.inbound_path(spec)
929
1464
  return nil unless File.exist?(inbound)
@@ -931,12 +1466,38 @@ module Scint
931
1466
  begin
932
1467
  metadata = GemPkg::Package.new.read_metadata(inbound)
933
1468
  cache_gemspec(spec, metadata, cache)
1469
+ @gemspec_cache_lock.synchronize { @gemspec_cache[cache_key] = metadata }
934
1470
  metadata
935
1471
  rescue StandardError
936
1472
  nil
937
1473
  end
938
1474
  end
939
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
+
940
1501
  def load_cached_gemspec(spec, cache, extracted_path)
941
1502
  path = cache.spec_cache_path(spec)
942
1503
  return nil unless File.exist?(path)
@@ -996,79 +1557,386 @@ module Scint
996
1557
 
997
1558
  # --- Lockfile + runtime config ---
998
1559
 
999
- def write_lockfile(resolved, gemfile)
1000
- 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 = {}
1001
1707
 
1002
- # Build source objects for path and git gems
1003
1708
  gemfile.dependencies.each do |dep|
1004
1709
  opts = dep.source_options
1005
- if opts[:path]
1006
- sources << Source::Path.new(path: opts[:path], name: dep.name)
1007
- elsif opts[:git]
1008
- sources << Source::Git.new(
1009
- uri: opts[:git],
1010
- branch: opts[:branch],
1011
- tag: opts[:tag],
1012
- ref: opts[:ref],
1013
- )
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
1014
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
1015
1834
  end
1835
+ end
1016
1836
 
1017
- # Build rubygems sources -- collect all unique URIs
1018
- rubygems_uris = gemfile.sources
1019
- .select { |s| s[:type] == :rubygems }
1020
- .map { |s| s[:uri] }
1021
- .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
1022
1842
 
1023
- # Group URIs that share specs into one Source::Rubygems each.
1024
- # The default source gets all remotes that aren't a separate scoped source.
1025
- scoped_uris = Set.new
1026
- gemfile.dependencies.each do |dep|
1027
- src = dep.source_options[:source]
1028
- 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) }
1029
1867
  end
1868
+ out
1869
+ end
1870
+
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
1030
1891
 
1031
- # Each scoped URI gets its own source object
1032
- scoped_uris.each do |uri|
1033
- sources << Source::Rubygems.new(remotes: [uri])
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
1034
1897
  end
1898
+ platforms << "ruby"
1899
+ platforms.to_a
1900
+ end
1035
1901
 
1036
- # Default rubygems source with remaining remotes
1037
- default_remotes = rubygems_uris.reject { |u| scoped_uris.include?(u) }
1038
- default_remotes = ["https://rubygems.org"] if default_remotes.empty?
1039
- 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
1040
1915
 
1041
- lockfile_data = Lockfile::LockfileData.new(
1042
- specs: resolved,
1043
- dependencies: gemfile.dependencies.map { |d| { name: d.name, version_reqs: d.version_reqs } },
1044
- platforms: [Platform.local_platform.to_s, "ruby"].uniq,
1045
- sources: sources,
1046
- bundler_version: Scint::VERSION,
1047
- ruby_version: nil,
1048
- checksums: nil,
1049
- )
1916
+ return nil if checksums.empty?
1050
1917
 
1051
- content = Lockfile::Writer.write(lockfile_data)
1052
- 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")
1053
1923
  end
1054
1924
 
1055
1925
  def write_runtime_config(resolved, bundle_path)
1056
- ruby_dir = File.join(bundle_path, "ruby",
1057
- RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
1926
+ ruby_dir = Platform.ruby_install_dir(bundle_path)
1058
1927
 
1059
1928
  data = {}
1060
1929
  resolved.each do |spec|
1061
- full = spec_full_name(spec)
1930
+ full = SpecUtils.full_name(spec)
1062
1931
  gem_dir = File.join(ruby_dir, "gems", full)
1063
1932
  spec_file = File.join(ruby_dir, "specifications", "#{full}.gemspec")
1064
1933
  require_paths = read_require_paths(spec_file)
1065
1934
  load_paths = require_paths
1066
- .map { |rp| File.join(gem_dir, rp) }
1935
+ .map { |rp| expand_require_path(gem_dir, rp) }
1067
1936
  .select { |path| Dir.exist?(path) }
1068
1937
 
1069
1938
  default_lib = File.join(gem_dir, "lib")
1070
1939
  load_paths << default_lib if load_paths.empty? && Dir.exist?(default_lib)
1071
- load_paths.concat(detect_nested_lib_paths(gem_dir))
1072
1940
  load_paths.uniq!
1073
1941
 
1074
1942
  # Add ext load path if extensions exist
@@ -1076,6 +1944,12 @@ module Scint
1076
1944
  Platform.gem_arch, Platform.extension_api_version, full)
1077
1945
  load_paths << ext_dir if Dir.exist?(ext_dir)
1078
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
+
1079
1953
  data[spec.name] = {
1080
1954
  version: spec.version.to_s,
1081
1955
  load_paths: load_paths,
@@ -1096,26 +1970,40 @@ module Scint
1096
1970
  ["lib"]
1097
1971
  end
1098
1972
 
1099
- def detect_nested_lib_paths(gem_dir)
1100
- lib_dir = File.join(gem_dir, "lib")
1101
- 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?
1102
1976
 
1103
- children = Dir.children(lib_dir)
1104
- top_level_rb = children.any? do |entry|
1105
- path = File.join(lib_dir, entry)
1106
- 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
1107
1990
  end
1108
- return [] if top_level_rb
1109
1991
 
1110
- children
1111
- .map { |entry| File.join(lib_dir, entry) }
1112
- .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
+ []
1113
2003
  end
1114
2004
 
1115
2005
  def spec_full_name(spec)
1116
- base = "#{spec.name}-#{spec.version}"
1117
- plat = spec.respond_to?(:platform) ? spec.platform : nil
1118
- (plat.nil? || plat.to_s == "ruby" || plat.to_s.empty?) ? base : "#{base}-#{plat}"
2006
+ SpecUtils.full_name(spec)
1119
2007
  end
1120
2008
 
1121
2009
  def elapsed_ms_since(start_time)
@@ -1124,7 +2012,7 @@ module Scint
1124
2012
  end
1125
2013
 
1126
2014
  def force_purge_artifacts(resolved, bundle_path, cache)
1127
- ruby_dir = File.join(bundle_path, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
2015
+ ruby_dir = Platform.ruby_install_dir(bundle_path)
1128
2016
  ext_root = File.join(ruby_dir, "extensions", Platform.gem_arch, Platform.extension_api_version)
1129
2017
 
1130
2018
  resolved.each do |spec|
@@ -1160,6 +2048,28 @@ module Scint
1160
2048
  "#{format_elapsed(elapsed_ms)}, #{workers} #{noun} used"
1161
2049
  end
1162
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
+
1163
2073
  def warn_missing_bundle_gitignore_entry
1164
2074
  path = ".gitignore"
1165
2075
  return unless File.file?(path)