scint 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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"
@@ -22,7 +23,9 @@ require_relative "../downloader/pool"
22
23
  require_relative "../gem/package"
23
24
  require_relative "../gem/extractor"
24
25
  require_relative "../cache/layout"
26
+ require_relative "../cache/manifest"
25
27
  require_relative "../cache/metadata_store"
28
+ require_relative "../cache/validity"
26
29
  require_relative "../installer/planner"
27
30
  require_relative "../installer/linker"
28
31
  require_relative "../installer/preparer"
@@ -32,31 +35,51 @@ require_relative "../resolver/provider"
32
35
  require_relative "../resolver/resolver"
33
36
  require_relative "../credentials"
34
37
  require "open3"
38
+ require "set"
39
+ require "pathname"
35
40
 
36
41
  module Scint
37
42
  module CLI
38
43
  class Install
39
44
  RUNTIME_LOCK = "scint.lock.marshal"
40
45
 
41
- def initialize(argv = [])
46
+ def initialize(argv = [], without: nil, with: nil)
42
47
  @argv = argv
43
48
  @jobs = nil
44
49
  @path = nil
45
50
  @verbose = false
46
51
  @force = false
52
+ @without_groups = nil
53
+ @with_groups = nil
54
+ @download_pool = nil
55
+ @download_pool_lock = Thread::Mutex.new
56
+ @gemspec_cache = {}
57
+ @gemspec_cache_lock = Thread::Mutex.new
47
58
  parse_options
59
+ # Allow programmatic override (for tests)
60
+ @without_groups = Array(without).map(&:to_sym) if without
61
+ @with_groups = Array(with).map(&:to_sym) if with
62
+ end
63
+
64
+ def _tmark(label, t0)
65
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
66
+ $stderr.puts " [timing] #{label}: #{((now - t0) * 1000).round}ms" if ENV["SCINT_TIMING"]
67
+ now
48
68
  end
49
69
 
50
70
  def run
51
71
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
72
+ _t = start_time
52
73
 
53
74
  cache = Scint::Cache::Layout.new
75
+ cache_telemetry = Scint::Cache::Telemetry.new
54
76
  bundle_path = @path || ENV["BUNDLER_PATH"] || ".bundle"
55
77
  bundle_display = display_bundle_path(bundle_path)
56
78
  bundle_path = File.expand_path(bundle_path)
57
- worker_count = @jobs || [Platform.cpu_count * 2, 50].min
79
+ worker_count = [(@jobs || [Platform.cpu_count * 2, 50].min).to_i, 1].max
58
80
  compile_slots = compile_slots_for(worker_count)
59
- per_type_limits = install_task_limits(worker_count, compile_slots)
81
+ git_slots = git_slots_for(worker_count)
82
+ per_type_limits = install_task_limits(worker_count, compile_slots, git_slots)
60
83
  $stdout.puts "#{GREEN}💎#{RESET} Scintellating Gemfile into #{BOLD}#{bundle_display}#{RESET} #{DIM}(scint #{VERSION}, ruby #{RUBY_VERSION})#{RESET}"
61
84
  $stdout.puts
62
85
 
@@ -68,6 +91,7 @@ module Scint
68
91
  scheduler.start
69
92
 
70
93
  begin
94
+ _t = _tmark("startup", _t)
71
95
  # 2. Parse Gemfile
72
96
  gemfile = Scint::Gemfile::Parser.parse("Gemfile")
73
97
 
@@ -79,6 +103,7 @@ module Scint
79
103
  dep_count = gemfile.dependencies.size
80
104
  scheduler.scale_workers(dep_count)
81
105
 
106
+ _t = _tmark("parse_gemfile", _t)
82
107
  # 3. Enqueue index fetches for all sources immediately
83
108
  gemfile.sources.each do |source|
84
109
  scheduler.enqueue(:fetch_index, source[:uri] || source.to_s,
@@ -99,25 +124,39 @@ module Scint
99
124
  -> { clone_git_source(source, cache) })
100
125
  end
101
126
 
127
+ _t = _tmark("enqueue_fetches", _t)
102
128
  # 6. Wait for index fetches, then resolve
103
129
  scheduler.wait_for(:fetch_index)
130
+ _t = _tmark("wait_index", _t)
104
131
  scheduler.wait_for(:git_clone)
132
+ _t = _tmark("wait_git", _t)
105
133
 
106
134
  resolved = resolve(gemfile, lockfile, cache)
107
135
  resolved = dedupe_resolved_specs(adjust_meta_gems(resolved))
136
+ resolved = filter_excluded_gems(resolved, gemfile)
108
137
  force_purge_artifacts(resolved, bundle_path, cache) if @force
109
138
 
139
+ _t = _tmark("resolve", _t)
110
140
  # 7. Plan: diff resolved vs installed
111
- plan = Installer::Planner.plan(resolved, bundle_path, cache)
141
+ plan = Installer::Planner.plan(resolved, bundle_path, cache, telemetry: cache_telemetry)
112
142
  total_gems = resolved.size
113
143
  updated_gems = plan.count { |e| e.action != :skip }
114
144
  cached_gems = total_gems - updated_gems
115
145
  to_install = plan.reject { |e| e.action == :skip }
146
+ _t = _tmark("plan", _t)
116
147
 
117
148
  # Scale up for download/install phase based on actual work count
118
149
  scheduler.scale_workers(to_install.size)
119
150
 
151
+ # Warm-cache accelerator: pre-materialize cache-backed gem trees in
152
+ # batches so install workers avoid one cp process per gem.
153
+ bulk_prelink_gem_files(to_install, cache, bundle_path)
154
+ _t = _tmark("prelink", _t)
155
+
120
156
  if to_install.empty?
157
+ # Keep lock artifacts aligned even when everything is already installed.
158
+ write_lockfile(resolved, gemfile, lockfile)
159
+ write_runtime_config(resolved, bundle_path)
121
160
  elapsed_ms = elapsed_ms_since(start_time)
122
161
  worker_count = scheduler.stats[:workers]
123
162
  warn_missing_bundle_gitignore_entry
@@ -148,7 +187,9 @@ module Scint
148
187
  if errors.any?
149
188
  $stderr.puts "#{RED}Some gems failed to install:#{RESET}"
150
189
  errors.each do |err|
151
- $stderr.puts " #{BOLD}#{err[:name]}#{RESET}: #{err[:error].message}"
190
+ error = err[:error]
191
+ $stderr.puts " #{BOLD}#{err[:name]}#{RESET}: #{error.message}"
192
+ emit_network_error_details(error)
152
193
  end
153
194
  elsif stats[:failed] > 0
154
195
  $stderr.puts "#{YELLOW}Warning: #{stats[:failed]} jobs failed but no error details captured#{RESET}"
@@ -168,14 +209,22 @@ module Scint
168
209
  1
169
210
  else
170
211
  # 10. Write lockfile + runtime config only for successful installs
171
- write_lockfile(resolved, gemfile)
212
+ write_lockfile(resolved, gemfile, lockfile)
172
213
  write_runtime_config(resolved, bundle_path)
173
214
  warn_missing_bundle_gitignore_entry
174
215
  $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
216
  0
176
217
  end
177
218
  ensure
178
- scheduler.shutdown
219
+ begin
220
+ cache_telemetry.warn_if_needed(cache_root: cache.root)
221
+ ensure
222
+ begin
223
+ scheduler.shutdown
224
+ ensure
225
+ close_download_pool
226
+ end
227
+ end
179
228
  end
180
229
  end
181
230
 
@@ -207,18 +256,103 @@ module Scint
207
256
  def dedupe_resolved_specs(resolved)
208
257
  seen = {}
209
258
  resolved.each do |spec|
210
- key = "#{spec.name}-#{spec.version}-#{spec.platform}"
259
+ key = SpecUtils.full_key(spec)
211
260
  seen[key] ||= spec
212
261
  end
213
262
  seen.values
214
263
  end
215
264
 
265
+ # Determine which gem names should be excluded based on group settings.
266
+ # A gem is excluded if ALL of its group memberships are in excluded groups.
267
+ # Gems appearing in any non-excluded group are kept.
268
+ def excluded_gem_names(gemfile, resolved: nil)
269
+ excluded_groups = compute_excluded_groups(gemfile)
270
+ return Set.new if excluded_groups.empty?
271
+
272
+ # Build map: gem_name => set of all groups it appears in (across all declarations)
273
+ gem_groups = Hash.new { |h, k| h[k] = Set.new }
274
+ gemfile.dependencies.each do |dep|
275
+ dep.groups.each { |g| gem_groups[dep.name] << g }
276
+ end
277
+
278
+ # A gem is directly excluded if ALL its groups are excluded
279
+ directly_excluded = Set.new
280
+ gem_groups.each do |name, groups|
281
+ directly_excluded << name if groups.subset?(excluded_groups)
282
+ end
283
+
284
+ # If we have resolved specs, also exclude transitive-only deps
285
+ if resolved && directly_excluded.any?
286
+ exclude_transitive_deps(directly_excluded, resolved, gem_groups)
287
+ else
288
+ directly_excluded
289
+ end
290
+ end
291
+
292
+ # Filter resolved specs, removing gems that belong only to excluded groups.
293
+ def filter_excluded_gems(resolved, gemfile)
294
+ excluded = excluded_gem_names(gemfile, resolved: resolved)
295
+ return resolved if excluded.empty?
296
+
297
+ resolved.reject { |spec| excluded.include?(spec.name) }
298
+ end
299
+
300
+ private
301
+
302
+ def compute_excluded_groups(gemfile)
303
+ optional = Set.new(Array(gemfile.optional_groups))
304
+ without = Set.new(Array(@without_groups))
305
+ with = Set.new(Array(@with_groups))
306
+
307
+ # Optional groups are excluded by default unless explicitly included via --with
308
+ excluded = optional - with
309
+ # --without adds more groups to exclude
310
+ excluded.merge(without)
311
+ excluded
312
+ end
313
+
314
+ # Walk the dependency graph to find transitive deps that are ONLY
315
+ # reachable through excluded gems. Shared deps are kept.
316
+ def exclude_transitive_deps(directly_excluded, resolved, gem_groups)
317
+ # Build dependency graph: name => [dep_names]
318
+ dep_graph = {}
319
+ resolved.each do |spec|
320
+ dep_names = Array(spec.dependencies).filter_map do |dep|
321
+ if dep.is_a?(Hash)
322
+ dep[:name] || dep["name"]
323
+ elsif dep.respond_to?(:name)
324
+ dep.name
325
+ end
326
+ end
327
+ dep_graph[spec.name] = dep_names
328
+ end
329
+
330
+ all_names = Set.new(resolved.map(&:name))
331
+
332
+ # Start from Gemfile deps that are NOT excluded, then walk transitive deps
333
+ included_roots = gem_groups.keys.reject { |n| directly_excluded.include?(n) }
334
+
335
+ # BFS from included roots to find all reachable gems
336
+ reachable = Set.new
337
+ queue = included_roots.dup
338
+ while (name = queue.shift)
339
+ next if reachable.include?(name)
340
+ reachable << name
341
+ (dep_graph[name] || []).each { |dep| queue << dep }
342
+ end
343
+
344
+ # Everything not reachable from included roots is excluded
345
+ all_names - reachable
346
+ end
347
+
348
+ public
349
+
216
350
  # Install scint into the bundle by copying our own lib tree.
217
351
  # No download needed — we know exactly where we are.
218
352
  def install_builtin_gem(entry, bundle_path)
219
353
  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)
354
+ ruby_dir = Platform.ruby_install_dir(bundle_path)
355
+ full_name = SpecUtils.full_name(spec)
222
356
  scint_root = File.expand_path("../../..", __FILE__)
223
357
 
224
358
  # Copy gem files into gems/scint-x.y.z/lib/
@@ -270,7 +404,10 @@ module Scint
270
404
 
271
405
  def resolve(gemfile, lockfile, cache)
272
406
  # If lockfile is up-to-date, use its specs directly
273
- if lockfile && lockfile_current?(gemfile, lockfile)
407
+ if lockfile &&
408
+ lockfile_current?(gemfile, lockfile) &&
409
+ lockfile_dependency_graph_valid?(lockfile) &&
410
+ lockfile_git_source_mapping_valid?(lockfile, cache)
274
411
  return lockfile_to_resolved(lockfile)
275
412
  end
276
413
 
@@ -305,6 +442,7 @@ module Scint
305
442
  # Build path_gems: gem_name => { version:, dependencies:, source: }
306
443
  # for gems with path: or git: sources (skip compact index for these)
307
444
  path_gems = {}
445
+ git_source_metadata_cache = {}
308
446
  gemfile.dependencies.each do |dep|
309
447
  opts = dep.source_options
310
448
  next unless opts[:path] || opts[:git]
@@ -314,19 +452,57 @@ module Scint
314
452
 
315
453
  # Try to read version and deps from gemspec if it's a path gem
316
454
  if opts[:path]
317
- gemspec = find_gemspec(opts[:path], dep.name)
455
+ gemspec = find_gemspec(opts[:path], dep.name, glob: opts[:glob])
318
456
  if gemspec
319
457
  version = gemspec.version.to_s
320
458
  deps = gemspec.dependencies
321
459
  .select { |d| d.type == :runtime }
322
- .map { |d| [d.name, d.requirement.to_s] }
460
+ .map do |d|
461
+ requirement_parts = d.requirement.requirements.map { |op, req_version| "#{op} #{req_version}" }
462
+ [d.name, requirement_parts]
463
+ end
323
464
  end
324
465
  end
325
466
 
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
467
+ # For git gems, read version and dependencies from the available revision when possible.
468
+ if opts[:git]
469
+ git_source = find_matching_git_source(Array(lockfile&.sources), opts) || find_matching_git_source(gemfile.sources, opts)
470
+ revision_hint = git_source&.revision || git_source&.ref || opts[:ref] || opts[:branch] || opts[:tag] || "HEAD"
471
+ bare_repo = cache&.git_path(opts[:git])
472
+ if bare_repo && !Dir.exist?(bare_repo)
473
+ clone_git_repo(opts[:git], bare_repo)
474
+ elsif bare_repo && Dir.exist?(bare_repo)
475
+ fetch_git_repo(bare_repo)
476
+ end
477
+ if bare_repo && Dir.exist?(bare_repo)
478
+ begin
479
+ resolved_revision = resolve_git_revision(bare_repo, revision_hint)
480
+ cache_key = "#{opts[:git]}@#{resolved_revision}"
481
+ git_metadata = git_source_metadata_cache[cache_key]
482
+ unless git_metadata
483
+ git_metadata = build_git_path_gems_for_revision(
484
+ bare_repo,
485
+ resolved_revision,
486
+ glob: opts[:glob],
487
+ source_desc: opts[:git],
488
+ )
489
+ git_source_metadata_cache[cache_key] = git_metadata
490
+ end
491
+ path_gems.merge!(git_metadata)
492
+ current = git_metadata[dep.name]
493
+ if current
494
+ version = current[:version]
495
+ deps = current[:dependencies]
496
+ end
497
+ rescue StandardError
498
+ # Fall back to lockfile version below.
499
+ end
500
+ end
501
+
502
+ if lockfile && version == "0"
503
+ locked_spec = lockfile.specs.find { |s| s[:name] == dep.name }
504
+ version = locked_spec[:version] if locked_spec
505
+ end
330
506
  end
331
507
 
332
508
  source_desc = opts[:path] || opts[:git] || "local"
@@ -353,32 +529,158 @@ module Scint
353
529
  resolver.resolve
354
530
  end
355
531
 
356
- def find_gemspec(path, gem_name)
532
+ def find_gemspec(path, gem_name, glob: nil)
357
533
  return nil unless Dir.exist?(path)
358
534
 
535
+ glob_pattern = glob || Source::Path::DEFAULT_GLOB
359
536
  # Look for exact match first, then any gemspec
360
537
  candidates = [
361
538
  File.join(path, "#{gem_name}.gemspec"),
539
+ *Dir.glob(File.join(path, glob_pattern)),
362
540
  *Dir.glob(File.join(path, "*.gemspec")),
363
- ]
541
+ ].uniq
364
542
 
365
543
  candidates.each do |gs|
366
544
  next unless File.exist?(gs)
367
545
  begin
368
- spec = Gem::Specification.load(gs)
546
+ spec = SpecUtils.load_gemspec(gs, isolate: true)
369
547
  return spec if spec
370
- rescue StandardError
548
+ rescue SystemExit, StandardError
371
549
  nil
372
550
  end
373
551
  end
374
552
  nil
375
553
  end
376
554
 
555
+ def find_git_gemspec(bare_repo, revision, gem_name, glob: nil)
556
+ gemspec_paths = gemspec_paths_in_git_revision(bare_repo, revision)
557
+ return nil if gemspec_paths.empty?
558
+
559
+ path = gemspec_paths[gem_name.to_s]
560
+ if path.nil? && glob
561
+ glob_regex = git_glob_to_regex(glob)
562
+ path = gemspec_paths.values.find { |candidate| candidate.match?(glob_regex) }
563
+ end
564
+ path ||= gemspec_paths.values.first
565
+ return nil if path.nil?
566
+
567
+ load_git_gemspec(bare_repo, revision, path)
568
+ rescue StandardError
569
+ nil
570
+ end
571
+
572
+ def build_git_path_gems_for_revision(bare_repo, revision, glob: nil, source_desc: nil)
573
+ gemspec_paths = gemspec_paths_in_git_revision(bare_repo, revision)
574
+ return {} if gemspec_paths.empty?
575
+
576
+ glob_regex = glob ? git_glob_to_regex(glob) : nil
577
+ data = {}
578
+
579
+ with_git_worktree(bare_repo, revision) do |worktree|
580
+ gemspec_paths.each_value do |path|
581
+ next if glob_regex && !path.match?(glob_regex)
582
+
583
+ gemspec = load_gemspec_from_worktree(worktree, path)
584
+ next unless gemspec
585
+
586
+ deps = gemspec.dependencies
587
+ .select { |d| d.type == :runtime }
588
+ .map do |d|
589
+ requirement_parts = d.requirement.requirements.map { |op, req_version| "#{op} #{req_version}" }
590
+ [d.name, requirement_parts]
591
+ end
592
+
593
+ data[gemspec.name] = {
594
+ version: gemspec.version.to_s,
595
+ dependencies: deps,
596
+ source: source_desc || "local",
597
+ }
598
+ end
599
+ end
600
+
601
+ data
602
+ rescue StandardError
603
+ {}
604
+ end
605
+
606
+ def git_glob_to_regex(glob)
607
+ pattern = glob.to_s
608
+ escaped = Regexp.escape(pattern)
609
+ escaped = escaped.gsub("\\*\\*", ".*")
610
+ escaped = escaped.gsub("\\*", "[^/]*")
611
+ escaped = escaped.gsub("\\?", ".")
612
+ /\A#{escaped}\z/
613
+ end
614
+
377
615
  def lockfile_current?(gemfile, lockfile)
378
616
  return false unless lockfile
379
617
 
380
618
  locked_names = Set.new(lockfile.specs.map { |s| s[:name] })
381
- gemfile.dependencies.all? { |d| locked_names.include?(d.name) }
619
+ gemfile.dependencies.all? do |dep|
620
+ next true unless dependency_relevant_for_local_platform?(dep)
621
+
622
+ locked_names.include?(dep.name)
623
+ end
624
+ end
625
+
626
+ def lockfile_dependency_graph_valid?(lockfile)
627
+ return false unless lockfile
628
+
629
+ specs = Array(lockfile.specs)
630
+ return false if specs.empty?
631
+
632
+ by_name = Hash.new { |h, k| h[k] = [] }
633
+ specs.each { |spec| by_name[spec[:name].to_s] << spec }
634
+
635
+ specs.all? do |spec|
636
+ Array(spec[:dependencies]).all? do |dep|
637
+ dep_name = dep[:name].to_s
638
+ # Lockfiles generally do not include a concrete "bundler" spec
639
+ # entry even though many gemspecs declare a runtime dependency
640
+ # on bundler. Treat it as externally satisfied.
641
+ next true if dep_name == "bundler"
642
+
643
+ dep_reqs = Array(dep[:version_reqs])
644
+ req = Gem::Requirement.new(dep_reqs.empty? ? [">= 0"] : dep_reqs)
645
+ by_name[dep_name].any? { |candidate| req.satisfied_by?(Gem::Version.new(candidate[:version].to_s)) }
646
+ end
647
+ end
648
+ rescue StandardError
649
+ false
650
+ end
651
+
652
+ def dependency_relevant_for_local_platform?(dependency)
653
+ platforms = Array(dependency.platforms).map(&:to_sym)
654
+ return true if platforms.empty?
655
+
656
+ platforms.any? { |platform| gemfile_platform_matches_local?(platform) }
657
+ end
658
+
659
+ def gemfile_platform_matches_local?(platform)
660
+ case platform
661
+ when :ruby
662
+ true
663
+ when :mri
664
+ RUBY_ENGINE == "ruby"
665
+ when :jruby
666
+ RUBY_ENGINE == "jruby"
667
+ when :truffleruby
668
+ RUBY_ENGINE == "truffleruby"
669
+ when :rbx
670
+ RUBY_ENGINE == "rbx"
671
+ when :windows, :mswin, :mswin64, :mingw, :x64_mingw, :x86_mingw, :x64_mingw_ucrt
672
+ Platform.windows?
673
+ when :linux
674
+ Platform.linux?
675
+ when :darwin, :macos
676
+ Platform.macos?
677
+ else
678
+ platform_name = platform.to_s.tr("_", "-")
679
+ spec_platform = Gem::Platform.new(platform_name)
680
+ spec_platform === Platform.local_platform
681
+ end
682
+ rescue StandardError
683
+ false
382
684
  end
383
685
 
384
686
  def lockfile_to_resolved(lockfile)
@@ -414,6 +716,106 @@ module Scint
414
716
  apply_locked_platform_preferences(resolved)
415
717
  end
416
718
 
719
+ def lockfile_git_source_mapping_valid?(lockfile, cache)
720
+ return true unless lockfile && cache
721
+
722
+ git_specs = Array(lockfile.specs).select { |s| s[:source].is_a?(Source::Git) }
723
+ return true if git_specs.empty?
724
+
725
+ by_source = git_specs.group_by { |s| s[:source] }
726
+ by_source.each do |source, specs|
727
+ uri, revision = git_source_ref(source)
728
+ bare_repo = cache.git_path(uri)
729
+ # Do not invalidate an otherwise-usable lockfile just because this
730
+ # git source has not been cached yet in the current machine/session.
731
+ next unless Dir.exist?(bare_repo)
732
+
733
+ resolved_revision = begin
734
+ resolve_git_revision(bare_repo, revision)
735
+ rescue InstallError
736
+ nil
737
+ end
738
+ return false unless resolved_revision
739
+
740
+ gemspec_paths = gemspec_paths_in_git_revision(bare_repo, resolved_revision)
741
+ gemspec_names = gemspec_paths.keys.to_set
742
+ return false if gemspec_names.empty?
743
+
744
+ specs.each do |spec|
745
+ return false unless gemspec_names.include?(spec[:name].to_s)
746
+ end
747
+ end
748
+
749
+ true
750
+ end
751
+
752
+ def gemspec_paths_in_git_revision(bare_repo, revision)
753
+ out, _err, status = git_capture3(
754
+ "--git-dir", bare_repo,
755
+ "ls-tree",
756
+ "-r",
757
+ "--name-only",
758
+ revision,
759
+ )
760
+ return {} unless status.success?
761
+
762
+ paths = {}
763
+ out.each_line do |line|
764
+ path = line.strip
765
+ next unless path.end_with?(".gemspec")
766
+ name = File.basename(path, ".gemspec")
767
+ paths[name] ||= path
768
+ end
769
+ paths
770
+ rescue StandardError
771
+ {}
772
+ end
773
+
774
+ def runtime_dependencies_for_git_gemspec(bare_repo, revision, gemspec_path)
775
+ spec = load_git_gemspec(bare_repo, revision, gemspec_path)
776
+ return nil unless spec
777
+
778
+ spec.dependencies.select { |dep| dep.type == :runtime }
779
+ rescue StandardError
780
+ nil
781
+ end
782
+
783
+ def load_git_gemspec(bare_repo, revision, gemspec_path)
784
+ return nil if gemspec_path.to_s.empty?
785
+
786
+ with_git_worktree(bare_repo, revision) do |worktree|
787
+ load_gemspec_from_worktree(worktree, gemspec_path)
788
+ end
789
+ rescue StandardError
790
+ nil
791
+ end
792
+
793
+ def with_git_worktree(bare_repo, revision)
794
+ worktree = Dir.mktmpdir("scint-gemspec")
795
+ _out, _err, status = git_capture3(
796
+ "--git-dir", bare_repo,
797
+ "--work-tree", worktree,
798
+ "checkout",
799
+ "--force",
800
+ revision,
801
+ )
802
+ return nil unless status.success?
803
+
804
+ File.write(File.join(worktree, ".git"), "gitdir: #{bare_repo}\n")
805
+ yield worktree if block_given?
806
+ ensure
807
+ FileUtils.rm_rf(worktree) if worktree && !worktree.empty?
808
+ end
809
+
810
+ def load_gemspec_from_worktree(worktree, gemspec_path)
811
+ absolute_gemspec = File.join(worktree, gemspec_path)
812
+ return nil unless File.exist?(absolute_gemspec)
813
+
814
+ SpecUtils.load_gemspec(absolute_gemspec, isolate: true)
815
+ rescue SystemExit, StandardError
816
+ nil
817
+ end
818
+
417
819
  # Preference: exact platform match > compatible match > ruby > first.
418
820
  def pick_best_platform_spec(specs, local_plat)
419
821
  return specs.first if specs.size == 1
@@ -451,7 +853,7 @@ module Scint
451
853
  return resolved_specs if preferred.empty?
452
854
 
453
855
  resolved_specs.each do |spec|
454
- key = "#{spec.name}-#{spec.version}"
856
+ key = SpecUtils.full_name_for(spec.name, spec.version)
455
857
  platform = preferred[key]
456
858
  next if platform.nil? || platform.empty?
457
859
 
@@ -478,7 +880,7 @@ module Scint
478
880
  preferred = preferred.to_s
479
881
  next if preferred.empty? || preferred == spec.platform.to_s
480
882
 
481
- out["#{spec.name}-#{spec.version}"] = preferred
883
+ out[SpecUtils.full_name_for(spec.name, spec.version)] = preferred
482
884
  end
483
885
  rescue StandardError
484
886
  next
@@ -496,7 +898,7 @@ module Scint
496
898
  spec = entry.spec
497
899
  source = spec.source
498
900
  if git_source?(source)
499
- prepare_git_source(entry, cache)
901
+ ensure_git_repo_for_spec(spec, cache, fetch: true)
500
902
  return
501
903
  end
502
904
  source_uri = source.to_s
@@ -504,7 +906,7 @@ module Scint
504
906
  # Path gems are not downloaded from a remote
505
907
  return if source_uri.start_with?("/") || !source_uri.start_with?("http")
506
908
 
507
- full_name = spec_full_name(spec)
909
+ full_name = SpecUtils.full_name(spec)
508
910
  gem_filename = "#{full_name}.gem"
509
911
  source_uri = source_uri.chomp("/")
510
912
  download_uri = "#{source_uri}/gems/#{gem_filename}"
@@ -513,9 +915,20 @@ module Scint
513
915
  FS.mkdir_p(File.dirname(dest_path))
514
916
 
515
917
  unless File.exist?(dest_path)
516
- pool = Downloader::Pool.new(size: 1, credentials: @credentials)
517
- pool.download(download_uri, dest_path)
518
- pool.close
918
+ downloader_pool.download(download_uri, dest_path)
919
+ end
920
+ end
921
+
922
+ def downloader_pool
923
+ @download_pool_lock.synchronize do
924
+ @download_pool ||= Downloader::Pool.new(credentials: @credentials)
925
+ end
926
+ end
927
+
928
+ def close_download_pool
929
+ @download_pool_lock.synchronize do
930
+ @download_pool&.close
931
+ @download_pool = nil
519
932
  end
520
933
  end
521
934
 
@@ -523,20 +936,37 @@ module Scint
523
936
  spec = entry.spec
524
937
  source_uri = spec.source.to_s
525
938
 
526
- # Git/path gems are already materialized by checkout or local path.
527
- return if git_source?(spec.source)
939
+ # Git gems are extracted from the cached checkout; path gems are
940
+ # linked directly from local source.
941
+ if git_source?(spec.source)
942
+ assemble_git_spec(entry, cache, fetch: false)
943
+ return
944
+ end
528
945
  return if source_uri.start_with?("/") || !source_uri.start_with?("http")
529
946
 
530
- extracted = cache.extracted_path(spec)
531
- return if Dir.exist?(extracted)
947
+ return if Cache::Validity.cached_valid?(spec, cache)
532
948
 
533
949
  dest_path = cache.inbound_path(spec)
534
950
  raise InstallError, "Missing cached gem file for #{spec.name}: #{dest_path}" unless File.exist?(dest_path)
535
951
 
536
- FS.mkdir_p(extracted)
537
- pkg = GemPkg::Package.new
538
- result = pkg.extract(dest_path, extracted)
539
- cache_gemspec(spec, result[:gemspec], cache)
952
+ assembling = cache.assembling_path(spec)
953
+ tmp = "#{assembling}.#{Process.pid}.#{Thread.current.object_id}.tmp"
954
+ begin
955
+ FileUtils.rm_rf(assembling)
956
+ FileUtils.rm_rf(tmp)
957
+ FS.mkdir_p(File.dirname(assembling))
958
+
959
+ pkg = GemPkg::Package.new
960
+ result = pkg.extract(dest_path, tmp)
961
+ FS.atomic_move(tmp, assembling)
962
+ cache_gemspec(spec, result[:gemspec], cache)
963
+
964
+ unless Installer::ExtensionBuilder.needs_build?(spec, assembling)
965
+ promote_assembled_gem(spec, cache, assembling, result[:gemspec], extensions: false)
966
+ end
967
+ ensure
968
+ FileUtils.rm_rf(tmp) if tmp && File.exist?(tmp)
969
+ end
540
970
  end
541
971
 
542
972
  def git_source?(source)
@@ -546,50 +976,135 @@ module Scint
546
976
  source_str.end_with?(".git") || source_str.include?(".git/")
547
977
  end
548
978
 
979
+ def path_source?(source)
980
+ return true if source.is_a?(Source::Path)
981
+
982
+ source_str =
983
+ if source.respond_to?(:path)
984
+ source.path.to_s
985
+ else
986
+ source.to_s
987
+ end
988
+ return false if source_str.empty?
989
+ return false if source_str.start_with?("http://", "https://")
990
+ return false if git_source?(source)
991
+
992
+ source_str.start_with?("/", ".", "~")
993
+ end
994
+
549
995
  def prepare_git_source(entry, cache)
996
+ # Legacy helper used by tests/callers that expect git download+extract
997
+ # in a single step.
998
+ assemble_git_spec(entry, cache, fetch: true)
999
+ end
1000
+
1001
+ def ensure_git_repo_for_spec(spec, cache, fetch:)
1002
+ source = spec.source
1003
+ uri, _revision = git_source_ref(source)
1004
+ bare_repo = cache.git_path(uri)
1005
+
1006
+ # Serialize all git operations per bare repo — git uses index.lock
1007
+ # and can't handle concurrent checkouts from the same repo.
1008
+ git_mutex_for(bare_repo).synchronize do
1009
+ if Dir.exist?(bare_repo)
1010
+ fetch_git_repo(bare_repo) if fetch
1011
+ else
1012
+ clone_git_repo(uri, bare_repo)
1013
+ fetch_git_repo(bare_repo)
1014
+ end
1015
+ end
1016
+
1017
+ bare_repo
1018
+ end
1019
+
1020
+ def assemble_git_spec(entry, cache, fetch: true)
550
1021
  spec = entry.spec
1022
+ return if Cache::Validity.cached_valid?(spec, cache)
1023
+
551
1024
  source = spec.source
552
1025
  uri, revision = git_source_ref(source)
1026
+ submodules = git_source_submodules?(source)
553
1027
 
554
1028
  bare_repo = cache.git_path(uri)
555
1029
 
556
1030
  # Serialize all git operations per bare repo — git uses index.lock
557
1031
  # and can't handle concurrent checkouts from the same repo.
558
1032
  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)
1033
+ tmp_checkout = nil
1034
+ tmp_assembled = nil
561
1035
 
562
- resolved_revision = resolve_git_revision(bare_repo, revision)
1036
+ begin
1037
+ if Dir.exist?(bare_repo)
1038
+ fetch_git_repo(bare_repo) if fetch
1039
+ else
1040
+ clone_git_repo(uri, bare_repo)
1041
+ fetch_git_repo(bare_repo)
1042
+ end
563
1043
 
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
1044
+ resolved_revision = resolve_git_revision(bare_repo, revision)
1045
+ assembling = cache.assembling_path(spec)
1046
+ tmp_checkout = "#{assembling}.checkout.#{Process.pid}.#{Thread.current.object_id}.tmp"
1047
+ tmp_assembled = "#{assembling}.#{Process.pid}.#{Thread.current.object_id}.tmp"
1048
+ promoter = cache_promoter(cache)
1049
+
1050
+ FileUtils.rm_rf(assembling)
1051
+ FileUtils.rm_rf(tmp_checkout)
1052
+ FileUtils.rm_rf(tmp_assembled)
1053
+ FS.mkdir_p(File.dirname(assembling))
1054
+
1055
+ promoter.validate_within_root!(cache.root, assembling, label: "assembling")
1056
+ promoter.validate_within_root!(cache.root, tmp_checkout, label: "git-checkout")
1057
+ promoter.validate_within_root!(cache.root, tmp_assembled, label: "git-assembled")
1058
+
1059
+ if submodules
1060
+ checkout_git_tree_with_submodules(
1061
+ bare_repo,
1062
+ tmp_checkout,
1063
+ resolved_revision,
1064
+ spec,
1065
+ uri,
1066
+ )
1067
+ else
1068
+ checkout_git_tree(
1069
+ bare_repo,
1070
+ tmp_checkout,
1071
+ resolved_revision,
1072
+ spec,
1073
+ uri,
1074
+ )
1075
+ end
569
1076
 
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",
580
- resolved_revision,
581
- "--",
582
- ".",
583
- )
584
- unless status.success?
585
- raise InstallError, "Git checkout failed for #{spec.name} (#{uri}@#{resolved_revision}): #{err.to_s.strip}"
1077
+ # Remove .git artifacts from checkout so assembled output is
1078
+ # deterministic and contains no git internals.
1079
+ Dir.glob(File.join(tmp_checkout, "**", ".git"), File::FNM_DOTMATCH).each do |path|
1080
+ FileUtils.rm_rf(path)
1081
+ end
1082
+
1083
+ gem_root = resolve_git_gem_subdir(tmp_checkout, spec)
1084
+ gem_rel = git_relative_root(tmp_checkout, gem_root)
1085
+ dest_root = tmp_assembled
1086
+ dest_path = gem_rel.empty? ? dest_root : File.join(dest_root, gem_rel)
1087
+
1088
+ promoter.validate_within_root!(cache.root, dest_path, label: "git-dest")
1089
+
1090
+ FS.clone_tree(gem_root, dest_path)
1091
+ copy_gemspec_root_files(tmp_checkout, gem_root, dest_root, spec)
1092
+ FS.atomic_move(tmp_assembled, assembling)
1093
+
1094
+ gem_subdir = begin
1095
+ resolve_git_gem_subdir(assembling, spec)
1096
+ rescue InstallError
1097
+ assembling
586
1098
  end
1099
+ gemspec = read_gemspec_from_extracted(gem_subdir, spec)
1100
+ cache_gemspec(spec, gemspec, cache) if gemspec
587
1101
 
588
- FileUtils.rm_rf(extracted)
589
- FS.atomic_move(tmp, extracted)
590
- FS.atomic_write(marker, "#{resolved_revision}\n")
1102
+ unless Installer::ExtensionBuilder.needs_build?(spec, assembling)
1103
+ promote_assembled_gem(spec, cache, assembling, gemspec, extensions: false)
1104
+ end
591
1105
  ensure
592
- FileUtils.rm_rf(tmp) if tmp && File.exist?(tmp)
1106
+ FileUtils.rm_rf(tmp_checkout) if tmp_checkout && File.exist?(tmp_checkout)
1107
+ FileUtils.rm_rf(tmp_assembled) if tmp_assembled && File.exist?(tmp_assembled)
593
1108
  end
594
1109
  end
595
1110
  end
@@ -603,6 +1118,117 @@ module Scint
603
1118
  [source.to_s, "HEAD"]
604
1119
  end
605
1120
 
1121
+ def git_source_submodules?(source)
1122
+ source.respond_to?(:submodules) && !!source.submodules
1123
+ end
1124
+
1125
+ def copy_gemspec_root_files(repo_root, gem_root, dest_root, spec)
1126
+ repo_root = File.expand_path(repo_root.to_s)
1127
+ gem_root = File.expand_path(gem_root.to_s)
1128
+ return if repo_root == gem_root
1129
+
1130
+ gemspec_path = git_gemspec_path_for_root(gem_root, spec)
1131
+ return unless gemspec_path && File.exist?(gemspec_path)
1132
+
1133
+ content = File.read(gemspec_path) rescue nil
1134
+ return unless content
1135
+
1136
+ root_files = git_root_files_from_gemspec(content)
1137
+ root_files.each do |file|
1138
+ source = File.join(repo_root, file)
1139
+ next unless File.file?(source)
1140
+
1141
+ dest = File.join(dest_root, file)
1142
+ next if File.exist?(dest)
1143
+
1144
+ FS.clonefile(source, dest)
1145
+ end
1146
+ end
1147
+
1148
+ def git_gemspec_path_for_root(gem_root, spec)
1149
+ if spec && spec.respond_to?(:name)
1150
+ candidate = File.join(gem_root, "#{spec.name}.gemspec")
1151
+ return candidate if File.exist?(candidate)
1152
+ end
1153
+
1154
+ Dir.glob(File.join(gem_root, "*.gemspec")).first
1155
+ end
1156
+
1157
+ def git_root_files_from_gemspec(content)
1158
+ files = ["RAILS_VERSION", "VERSION"]
1159
+ files.select { |file| content.include?(file) }
1160
+ end
1161
+
1162
+ def git_relative_root(repo_root, gem_root)
1163
+ repo_root = File.expand_path(repo_root.to_s)
1164
+ gem_root = File.expand_path(gem_root.to_s)
1165
+ return "" if repo_root == gem_root
1166
+
1167
+ if gem_root.start_with?("#{repo_root}/")
1168
+ return gem_root.delete_prefix("#{repo_root}/")
1169
+ end
1170
+
1171
+ File.basename(gem_root)
1172
+ end
1173
+
1174
+ def checkout_git_tree(bare_repo, destination, resolved_revision, spec, uri)
1175
+ FileUtils.mkdir_p(destination)
1176
+ _out, err, status = git_capture3(
1177
+ "--git-dir", bare_repo,
1178
+ "--work-tree", destination,
1179
+ "checkout",
1180
+ "-f",
1181
+ resolved_revision,
1182
+ "--",
1183
+ ".",
1184
+ )
1185
+ unless status.success?
1186
+ raise InstallError, "Git checkout failed for #{spec.name} (#{uri}@#{resolved_revision}): #{err.to_s.strip}"
1187
+ end
1188
+ end
1189
+
1190
+ def checkout_git_tree_with_submodules(bare_repo, destination, resolved_revision, spec, uri)
1191
+ worktree = "#{destination}.worktree"
1192
+ FileUtils.rm_rf(worktree)
1193
+
1194
+ _out, err, status = git_capture3(
1195
+ "--git-dir", bare_repo,
1196
+ "worktree",
1197
+ "add",
1198
+ "--detach",
1199
+ "--force",
1200
+ worktree,
1201
+ resolved_revision,
1202
+ )
1203
+ unless status.success?
1204
+ raise InstallError, "Git worktree checkout failed for #{spec.name} (#{uri}@#{resolved_revision}): #{err.to_s.strip}"
1205
+ end
1206
+
1207
+ begin
1208
+ _sub_out, sub_err, sub_status = git_capture3(
1209
+ "-C", worktree,
1210
+ "-c", "protocol.file.allow=always",
1211
+ "submodule",
1212
+ "update",
1213
+ "--init",
1214
+ "--recursive",
1215
+ )
1216
+ unless sub_status.success?
1217
+ raise InstallError, "Git submodule update failed for #{spec.name} (#{uri}@#{resolved_revision}): #{sub_err.to_s.strip}"
1218
+ end
1219
+
1220
+ FS.clone_tree(worktree, destination)
1221
+
1222
+ # Keep cache/extracted trees deterministic and detached from git internals.
1223
+ Dir.glob(File.join(destination, "**", ".git"), File::FNM_DOTMATCH).each do |path|
1224
+ FileUtils.rm_rf(path)
1225
+ end
1226
+ ensure
1227
+ git_capture3("--git-dir", bare_repo, "worktree", "remove", "--force", worktree)
1228
+ FileUtils.rm_rf(worktree)
1229
+ end
1230
+ end
1231
+
606
1232
  def git_mutex_for(repo_path)
607
1233
  @git_mutexes_lock ||= Thread::Mutex.new
608
1234
  @git_mutexes_lock.synchronize do
@@ -645,30 +1271,59 @@ module Scint
645
1271
  Open3.capture3("git", "-c", "core.fsmonitor=false", *args)
646
1272
  end
647
1273
 
648
- def git_checkout_marker_path(dir)
649
- "#{dir}.scint_git_revision"
1274
+ def compile_slots_for(worker_count)
1275
+ # Scale compile concurrency with available CPUs.
1276
+ # Most native extensions have 1-3 source files and don't benefit from
1277
+ # high make -j; running more concurrent builds is more effective.
1278
+ # Each slot gets cpu_count/slots make jobs (see adaptive_make_jobs).
1279
+ workers = [worker_count.to_i, 1].max
1280
+ override = positive_integer_env("SCINT_COMPILE_CONCURRENCY")
1281
+ return [override, workers].min if override
1282
+
1283
+ cpus = Platform.cpu_count
1284
+ # Aim for 8 make-jobs per slot → slots = cpus / 8, clamped.
1285
+ slots = cpus / 8
1286
+ slots = [[slots, 2].max, workers].min
1287
+ slots
650
1288
  end
651
1289
 
652
- 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
1290
+ def git_slots_for(worker_count)
1291
+ workers = [worker_count.to_i, 1].max
1292
+ override = positive_integer_env("SCINT_GIT_CONCURRENCY")
1293
+ slots = override || workers
1294
+ [[slots, workers].min, 1].max
657
1295
  end
658
1296
 
659
- def install_task_limits(worker_count, compile_slots)
1297
+ def install_task_limits(worker_count, compile_slots, git_slots = worker_count)
660
1298
  # Leave headroom for compile and binstub lanes so link/download
661
1299
  # throughput cannot fully starve them.
662
- io_cpu_limit = [worker_count - compile_slots - 1, 1].max
1300
+ workers = [worker_count.to_i, 1].max
1301
+ io_cpu_limit = [workers - compile_slots - 1, 1].max
1302
+ # Keep download in-flight set bounded so fail-fast exits quickly on
1303
+ # auth/source errors instead of queueing a large burst.
1304
+ download_limit = [io_cpu_limit, 8].min
1305
+ git_limit = [[git_slots.to_i, 1].max, workers].min
663
1306
  {
664
- download: io_cpu_limit,
1307
+ download: download_limit,
665
1308
  extract: io_cpu_limit,
666
1309
  link: io_cpu_limit,
1310
+ git_clone: git_limit,
667
1311
  build_ext: compile_slots,
668
1312
  binstub: 1,
669
1313
  }
670
1314
  end
671
1315
 
1316
+ def positive_integer_env(key)
1317
+ raw = ENV[key]
1318
+ return nil if raw.nil? || raw.empty?
1319
+
1320
+ value = Integer(raw, exception: false)
1321
+ return nil unless value
1322
+ return nil if value <= 0
1323
+
1324
+ value
1325
+ end
1326
+
672
1327
  def display_bundle_path(path)
673
1328
  return path if path.start_with?("/", "./", "../")
674
1329
 
@@ -770,7 +1425,7 @@ module Scint
770
1425
  end
771
1426
 
772
1427
  def spec_key(spec)
773
- "#{spec.name}-#{spec.version}-#{spec.platform}"
1428
+ SpecUtils.full_key(spec)
774
1429
  end
775
1430
 
776
1431
  def dependency_link_job_ids(spec, link_job_by_name)
@@ -805,11 +1460,36 @@ module Scint
805
1460
  def extracted_path_for_entry(entry, cache)
806
1461
  source_str = entry.spec.source.to_s
807
1462
  if source_str.start_with?("/") && Dir.exist?(source_str)
808
- source_str
1463
+ begin
1464
+ if path_source?(entry.spec.source)
1465
+ resolve_path_gem_subdir(source_str, entry.spec)
1466
+ else
1467
+ resolve_git_gem_subdir(source_str, entry.spec)
1468
+ end
1469
+ rescue InstallError
1470
+ source_str
1471
+ end
809
1472
  else
810
- base = entry.cached_path || cache.extracted_path(entry.spec)
811
- if git_source?(entry.spec.source) && Dir.exist?(base)
1473
+ cached_dir = cache.cached_path(entry.spec)
1474
+ assembling = cache.assembling_path(entry.spec)
1475
+ base = if entry.cached_path
1476
+ entry.cached_path
1477
+ elsif Cache::Validity.cached_valid?(entry.spec, cache)
1478
+ cached_dir
1479
+ elsif Dir.exist?(assembling)
1480
+ assembling
1481
+ else
1482
+ nil
1483
+ end
1484
+
1485
+ if git_source?(entry.spec.source) && base && Dir.exist?(base)
812
1486
  resolve_git_gem_subdir(base, entry.spec)
1487
+ elsif path_source?(entry.spec.source) && base && Dir.exist?(base)
1488
+ begin
1489
+ resolve_path_gem_subdir(base, entry.spec)
1490
+ rescue InstallError
1491
+ base
1492
+ end
813
1493
  else
814
1494
  base
815
1495
  end
@@ -826,8 +1506,40 @@ module Scint
826
1506
  Dir.glob(File.join(repo_root, glob)).each do |path|
827
1507
  return File.dirname(path) if File.basename(path, ".gemspec") == name
828
1508
  end
1509
+ # Compatibility fallback for monorepos whose gem layout does not match
1510
+ # the lockfile glob exactly.
1511
+ Dir.glob(File.join(repo_root, "**", "*.gemspec")).each do |path|
1512
+ return File.dirname(path) if File.basename(path, ".gemspec") == name
1513
+ end
1514
+
1515
+ source_uri = source.respond_to?(:uri) ? source.uri : source.to_s
1516
+ raise InstallError,
1517
+ "Git source #{source_uri} does not contain #{name}.gemspec (glob: #{glob.inspect}); lockfile source mapping may be stale"
1518
+ end
1519
+
1520
+ # For path monorepo sources, map gem name to its gemspec subdirectory.
1521
+ def resolve_path_gem_subdir(repo_root, spec)
1522
+ name = spec.name
1523
+ return repo_root if File.exist?(File.join(repo_root, "#{name}.gemspec"))
1524
+
1525
+ source = spec.source
1526
+ glob = source.respond_to?(:glob) ? source.glob : Source::Path::DEFAULT_GLOB
1527
+ Dir.glob(File.join(repo_root, glob)).each do |path|
1528
+ return File.dirname(path) if File.basename(path, ".gemspec") == name
1529
+ end
1530
+ Dir.glob(File.join(repo_root, "**", "*.gemspec")).each do |path|
1531
+ return File.dirname(path) if File.basename(path, ".gemspec") == name
1532
+ end
829
1533
 
830
- repo_root
1534
+ source_uri =
1535
+ if source.respond_to?(:path)
1536
+ source.path
1537
+ elsif source.respond_to?(:uri)
1538
+ source.uri
1539
+ else
1540
+ source.to_s
1541
+ end
1542
+ raise InstallError, "Path source #{source_uri} does not contain #{name}.gemspec (glob: #{glob.inspect})"
831
1543
  end
832
1544
 
833
1545
  def link_gem_files(entry, cache, bundle_path)
@@ -843,19 +1555,18 @@ module Scint
843
1555
  from_cache: true,
844
1556
  )
845
1557
  Installer::Linker.link_files(prepared, bundle_path)
846
- # If this gem has a cached native build, materialize it during link.
847
- # This lets reinstalling into a fresh .bundle skip build_ext entirely.
848
- Installer::ExtensionBuilder.link_cached_build(prepared, bundle_path, cache)
849
1558
  end
850
1559
 
851
1560
  def build_extensions(entry, cache, bundle_path, progress = nil, compile_slots: 1)
1561
+ spec = entry.spec
852
1562
  extracted = extracted_path_for_entry(entry, cache)
853
- gemspec = load_gemspec(extracted, entry.spec, cache)
1563
+ gemspec = load_gemspec(extracted, spec, cache)
1564
+ promote_after_build = assembling_path?(extracted, cache)
854
1565
 
855
- sync_build_env_dependencies(entry.spec, bundle_path, cache)
1566
+ sync_build_env_dependencies(spec, bundle_path, cache)
856
1567
 
857
1568
  prepared = PreparedGem.new(
858
- spec: entry.spec,
1569
+ spec: spec,
859
1570
  extracted_path: extracted,
860
1571
  gemspec: gemspec,
861
1572
  from_cache: true,
@@ -866,8 +1577,24 @@ module Scint
866
1577
  bundle_path,
867
1578
  cache,
868
1579
  compile_slots: compile_slots,
869
- output_tail: ->(lines) { progress&.on_build_tail(entry.spec.name, lines) },
1580
+ output_tail: ->(lines) { progress&.on_build_tail(spec.name, lines) },
870
1581
  )
1582
+
1583
+ ruby_dir = Platform.ruby_install_dir(bundle_path)
1584
+ bundle_gem_dir = File.join(ruby_dir, "gems", SpecUtils.full_name(spec))
1585
+ if Dir.exist?(bundle_gem_dir)
1586
+ Installer::ExtensionBuilder.sync_extensions_into_gem(extracted, bundle_gem_dir)
1587
+ File.write(File.join(bundle_gem_dir, Installer::ExtensionBuilder::BUILD_MARKER), "")
1588
+ end
1589
+
1590
+ return unless promote_after_build
1591
+
1592
+ promote_assembled_gem(spec, cache, extracted, gemspec, extensions: true)
1593
+ rescue StandardError
1594
+ if promote_after_build && extracted && Dir.exist?(extracted)
1595
+ FileUtils.rm_rf(extracted)
1596
+ end
1597
+ raise
871
1598
  end
872
1599
 
873
1600
  def sync_build_env_dependencies(spec, bundle_path, cache)
@@ -882,7 +1609,7 @@ module Scint
882
1609
  dep_names.uniq!
883
1610
  return if dep_names.empty?
884
1611
 
885
- source_ruby_dir = File.join(bundle_path, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
1612
+ source_ruby_dir = Platform.ruby_install_dir(bundle_path)
886
1613
  target_ruby_dir = cache.install_ruby_dir
887
1614
 
888
1615
  dep_names.each do |name|
@@ -922,8 +1649,21 @@ module Scint
922
1649
  end
923
1650
 
924
1651
  def load_gemspec(extracted_path, spec, cache)
1652
+ cache_key = "#{cache.full_name(spec)}@#{extracted_path}"
1653
+ cached_value = @gemspec_cache_lock.synchronize { @gemspec_cache[cache_key] }
1654
+ return cached_value if cached_value
1655
+
925
1656
  cached = load_cached_gemspec(spec, cache, extracted_path)
926
- return cached if cached
1657
+ if cached
1658
+ @gemspec_cache_lock.synchronize { @gemspec_cache[cache_key] = cached }
1659
+ return cached
1660
+ end
1661
+
1662
+ direct = read_gemspec_from_extracted(extracted_path, spec)
1663
+ if direct
1664
+ @gemspec_cache_lock.synchronize { @gemspec_cache[cache_key] = direct }
1665
+ return direct
1666
+ end
927
1667
 
928
1668
  inbound = cache.inbound_path(spec)
929
1669
  return nil unless File.exist?(inbound)
@@ -931,30 +1671,97 @@ module Scint
931
1671
  begin
932
1672
  metadata = GemPkg::Package.new.read_metadata(inbound)
933
1673
  cache_gemspec(spec, metadata, cache)
1674
+ @gemspec_cache_lock.synchronize { @gemspec_cache[cache_key] = metadata }
934
1675
  metadata
935
1676
  rescue StandardError
936
1677
  nil
937
1678
  end
938
1679
  end
939
1680
 
1681
+ def read_gemspec_from_extracted(extracted_dir, spec)
1682
+ return nil unless extracted_dir && Dir.exist?(extracted_dir)
1683
+
1684
+ pattern = File.join(extracted_dir, "*.gemspec")
1685
+ candidates = Dir.glob(pattern)
1686
+ return nil if candidates.empty?
1687
+
1688
+ load_gemspec_file(candidates.first, spec)
1689
+ end
1690
+
1691
+ # Load a .gemspec file, temporarily injecting VERSION env var for gems
1692
+ # like kgio/unicorn that use `ENV["VERSION"] or abort` in their gemspec.
1693
+ def load_gemspec_file(path, spec = nil)
1694
+ version = spec.respond_to?(:version) ? spec.version.to_s : nil
1695
+ old_version = ENV["VERSION"]
1696
+ begin
1697
+ ENV["VERSION"] = version if version && !ENV["VERSION"]
1698
+ SpecUtils.load_gemspec(path, isolate: true)
1699
+ rescue SystemExit, StandardError
1700
+ nil
1701
+ ensure
1702
+ ENV["VERSION"] = old_version
1703
+ end
1704
+ end
1705
+
1706
+ def bulk_prelink_gem_files(entries, cache, bundle_path)
1707
+ return if entries.length < 32
1708
+
1709
+ ruby_dir = File.join(bundle_path, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
1710
+ gems_dir = File.join(ruby_dir, "gems")
1711
+ cache_abi_dir = cache.cached_abi_dir
1712
+
1713
+ gem_names = []
1714
+ entries.each do |entry|
1715
+ next unless entry.action == :link || entry.action == :build_ext
1716
+
1717
+ source_dir = entry.cached_path
1718
+ next unless source_dir
1719
+
1720
+ full_name = cache.full_name(entry.spec)
1721
+ next unless File.basename(source_dir) == full_name
1722
+ next unless Dir.exist?(source_dir)
1723
+ next if Dir.exist?(File.join(gems_dir, full_name))
1724
+
1725
+ gem_names << full_name
1726
+ end
1727
+
1728
+ return if gem_names.empty?
1729
+
1730
+ if ENV["SCINT_TIMING"]
1731
+ $stderr.puts " [timing] prelink: #{gem_names.size} gems via linker"
1732
+ end
1733
+
1734
+ FS.bulk_link_gems(cache_abi_dir, gems_dir, gem_names)
1735
+ rescue StandardError => e
1736
+ $stderr.puts("bulk prelink warning: #{e.message}") if ENV["SCINT_DEBUG"]
1737
+ end
1738
+
940
1739
  def load_cached_gemspec(spec, cache, extracted_path)
941
- path = cache.spec_cache_path(spec)
942
- return nil unless File.exist?(path)
1740
+ paths = [cache.cached_spec_path(spec)]
943
1741
 
944
- data = File.binread(path)
945
- gemspec = if data.start_with?("---")
946
- Gem::Specification.from_yaml(data)
947
- else
948
- begin
949
- Marshal.load(data)
950
- rescue StandardError
1742
+ paths.each do |path|
1743
+ next unless File.exist?(path)
1744
+
1745
+ data = File.binread(path)
1746
+ gemspec = if data.start_with?("# -*- encoding")
1747
+ # Ruby format (to_ruby output) — most reliable, preserves require_paths
1748
+ Gem::Specification.load(path)
1749
+ elsif data.start_with?("---")
1750
+ data.force_encoding("UTF-8") if data.encoding != Encoding::UTF_8
951
1751
  Gem::Specification.from_yaml(data)
1752
+ else
1753
+ begin
1754
+ Marshal.load(data)
1755
+ rescue StandardError
1756
+ data.force_encoding("UTF-8") if data.encoding != Encoding::UTF_8
1757
+ Gem::Specification.from_yaml(data)
1758
+ end
952
1759
  end
1760
+ return gemspec if cached_gemspec_valid?(gemspec, extracted_path)
953
1761
  end
954
- return gemspec if cached_gemspec_valid?(gemspec, extracted_path)
955
1762
 
956
1763
  nil
957
- rescue StandardError
1764
+ rescue SystemExit, StandardError
958
1765
  nil
959
1766
  end
960
1767
 
@@ -988,87 +1795,490 @@ module Scint
988
1795
  end
989
1796
 
990
1797
  def cache_gemspec(spec, gemspec, cache)
991
- path = cache.spec_cache_path(spec)
992
- FS.atomic_write(path, gemspec.to_yaml)
1798
+ path = cache.cached_spec_path(spec)
1799
+ content = if gemspec.respond_to?(:to_ruby)
1800
+ gemspec.to_ruby
1801
+ else
1802
+ gemspec.to_yaml
1803
+ end
1804
+ FS.atomic_write(path, content)
993
1805
  rescue StandardError
994
1806
  # Non-fatal: we'll read metadata from .gem next time.
995
1807
  end
996
1808
 
997
- # --- Lockfile + runtime config ---
1809
+ def cache_promoter(cache)
1810
+ @cache_promoter ||= Installer::Promoter.new(root: cache.root)
1811
+ end
998
1812
 
999
- def write_lockfile(resolved, gemfile)
1000
- sources = []
1813
+ def assembling_path?(path, cache)
1814
+ return false if path.nil? || path.empty?
1001
1815
 
1002
- # Build source objects for path and git gems
1003
- gemfile.dependencies.each do |dep|
1004
- 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],
1816
+ root = File.expand_path(cache.assembling_dir)
1817
+ candidate = File.expand_path(path)
1818
+ candidate == root || candidate.start_with?("#{root}/")
1819
+ end
1820
+
1821
+ def promote_assembled_gem(spec, cache, assembling_path, gemspec, extensions:)
1822
+ return unless assembling_path && Dir.exist?(assembling_path)
1823
+
1824
+ cached_dir = cache.cached_path(spec)
1825
+ promoter = cache_promoter(cache)
1826
+ lock_key = "#{Platform.abi_key}-#{cache.full_name(spec)}"
1827
+
1828
+ promoter.validate_within_root!(cache.root, assembling_path, label: "assembling")
1829
+ promoter.validate_within_root!(cache.root, cached_dir, label: "cached")
1830
+
1831
+ begin
1832
+ result = nil
1833
+ promoter.with_staging_dir(prefix: "cached") do |staging|
1834
+ FS.clone_tree(assembling_path, staging)
1835
+ manifest = build_cached_manifest(spec, cache, staging, extensions: extensions)
1836
+ Cache::Manifest.write_dotfiles(staging, manifest)
1837
+ spec_payload = gemspec ? gemspec.to_ruby : nil
1838
+ result = promoter.promote_tree(
1839
+ staging_path: staging,
1840
+ target_path: cached_dir,
1841
+ lock_key: lock_key,
1013
1842
  )
1843
+ if result == :promoted
1844
+ write_cached_metadata(spec, cache, spec_payload, manifest)
1845
+ end
1846
+ FileUtils.rm_rf(assembling_path) if Dir.exist?(assembling_path)
1014
1847
  end
1848
+ result
1849
+ rescue StandardError
1850
+ FileUtils.rm_rf(cached_dir) if Dir.exist?(cached_dir)
1851
+ raise
1015
1852
  end
1853
+ end
1016
1854
 
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
1855
+ def write_cached_metadata(spec, cache, spec_payload, manifest)
1856
+ spec_path = cache.cached_spec_path(spec)
1857
+ manifest_path = cache.cached_manifest_path(spec)
1858
+ FS.mkdir_p(File.dirname(spec_path))
1022
1859
 
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
1029
- end
1860
+ FS.atomic_write(spec_path, spec_payload) if spec_payload
1861
+ Cache::Manifest.write(manifest_path, manifest)
1862
+ end
1863
+
1864
+ def build_cached_manifest(spec, cache, gem_dir, extensions:)
1865
+ Cache::Manifest.build(
1866
+ spec: spec,
1867
+ gem_dir: gem_dir,
1868
+ abi_key: Platform.abi_key,
1869
+ source: manifest_source_for(spec),
1870
+ extensions: extensions,
1871
+ )
1872
+ end
1030
1873
 
1031
- # Each scoped URI gets its own source object
1032
- scoped_uris.each do |uri|
1033
- sources << Source::Rubygems.new(remotes: [uri])
1874
+ def manifest_source_for(spec)
1875
+ source = spec.source
1876
+ if source.is_a?(Source::Git)
1877
+ {
1878
+ "type" => "git",
1879
+ "uri" => source.uri.to_s,
1880
+ "revision" => source.revision || source.ref || source.branch || source.tag,
1881
+ }.compact
1882
+ elsif source.is_a?(Source::Path)
1883
+ {
1884
+ "type" => "path",
1885
+ "path" => File.expand_path(source.path.to_s),
1886
+ "uri" => source.path.to_s,
1887
+ }
1888
+ else
1889
+ source_str = source.to_s
1890
+ if source_str.start_with?("http://", "https://")
1891
+ { "type" => "rubygems", "uri" => source_str }
1892
+ elsif path_source?(source)
1893
+ { "type" => "path", "path" => File.expand_path(source_str), "uri" => source_str }
1894
+ else
1895
+ { "type" => "rubygems", "uri" => source_str }
1896
+ end
1034
1897
  end
1898
+ end
1899
+
1900
+ # --- Lockfile + runtime config ---
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 write_lockfile(resolved, gemfile, lockfile = nil)
1903
+ specs, sources, preserved_layout = build_lockfile_specs_and_sources(resolved, gemfile, lockfile)
1040
1904
 
1041
1905
  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,
1906
+ specs: specs,
1907
+ dependencies: build_lockfile_dependencies(gemfile, lockfile),
1908
+ platforms: preserved_layout && lockfile ? Array(lockfile.platforms) : build_lockfile_platforms(specs, lockfile),
1045
1909
  sources: sources,
1046
- bundler_version: Scint::VERSION,
1047
- ruby_version: nil,
1048
- checksums: nil,
1910
+ bundler_version: lockfile&.bundler_version || Scint::VERSION,
1911
+ ruby_version: lockfile&.ruby_version || gemfile.ruby_version,
1912
+ checksums: preserved_layout && lockfile ? lockfile.checksums : build_lockfile_checksums(specs, lockfile),
1049
1913
  )
1050
1914
 
1051
1915
  content = Lockfile::Writer.write(lockfile_data)
1052
1916
  FS.atomic_write("Gemfile.lock", content)
1053
1917
  end
1054
1918
 
1919
+ def build_lockfile_specs_and_sources(resolved, gemfile, lockfile)
1920
+ resolved_for_lockfile = filter_lockfile_specs(resolved)
1921
+
1922
+ if preserve_existing_lockfile_specs?(resolved_for_lockfile, lockfile)
1923
+ specs = Array(lockfile.specs).map { |spec| normalize_lockfile_spec(spec) }
1924
+ sources = uniq_sources(Array(lockfile.sources))
1925
+ return [specs, sources, true]
1926
+ end
1927
+
1928
+ dependency_sources = dependency_sources_from_gemfile(gemfile, lockfile)
1929
+ existing_sources = Array(lockfile&.sources)
1930
+ candidate_sources = uniq_sources(existing_sources + dependency_sources.values)
1931
+
1932
+ rubygems_uris = collect_lockfile_rubygems_uris(gemfile)
1933
+ if rubygems_uris.empty? && candidate_sources.none? { |src| src.is_a?(Source::Rubygems) }
1934
+ rubygems_uris << "https://rubygems.org"
1935
+ end
1936
+ rubygems_uris.each do |uri|
1937
+ source = find_matching_rubygems_source(candidate_sources, uri)
1938
+ candidate_sources << Source::Rubygems.new(remotes: [uri]) unless source
1939
+ end
1940
+ candidate_sources = uniq_sources(candidate_sources)
1941
+
1942
+ lock_source_by_full, lock_source_by_name_version = lockfile_sources_by_spec_key(lockfile)
1943
+ default_rubygems_source = candidate_sources.find { |src| src.is_a?(Source::Rubygems) }
1944
+
1945
+ specs = resolved_for_lockfile.map do |spec|
1946
+ normalized = normalize_resolved_spec(spec)
1947
+ source = source_for_spec(
1948
+ normalized,
1949
+ dependency_sources: dependency_sources,
1950
+ candidate_sources: candidate_sources,
1951
+ lock_source_by_full: lock_source_by_full,
1952
+ lock_source_by_name_version: lock_source_by_name_version,
1953
+ fallback: default_rubygems_source,
1954
+ )
1955
+ normalized.merge(source: source)
1956
+ end
1957
+
1958
+ sources = uniq_sources(specs.map { |spec| spec[:source] }.compact)
1959
+ if sources.empty?
1960
+ fallback = default_rubygems_source || Source::Rubygems.new(remotes: ["https://rubygems.org"])
1961
+ sources = [fallback]
1962
+ specs.each { |spec| spec[:source] = fallback }
1963
+ end
1964
+
1965
+ [specs, sources, false]
1966
+ end
1967
+
1968
+ def filter_lockfile_specs(specs)
1969
+ specs.reject do |spec|
1970
+ name = spec.is_a?(Hash) ? spec[:name].to_s : spec.name.to_s
1971
+ name == "scint"
1972
+ end
1973
+ end
1974
+
1975
+ def preserve_existing_lockfile_specs?(resolved, lockfile)
1976
+ return false unless lockfile && lockfile.respond_to?(:specs)
1977
+
1978
+ wanted = resolved.map { |spec| [spec.name.to_s, spec.version.to_s] }.uniq
1979
+ return false if wanted.empty?
1980
+
1981
+ available = Set.new
1982
+ Array(lockfile.specs).each do |spec|
1983
+ available << [spec[:name].to_s, spec[:version].to_s]
1984
+ end
1985
+
1986
+ wanted.all? { |tuple| available.include?(tuple) }
1987
+ end
1988
+
1989
+ def normalize_lockfile_spec(spec)
1990
+ if spec.is_a?(Hash)
1991
+ {
1992
+ name: spec[:name],
1993
+ version: spec[:version],
1994
+ platform: spec[:platform] || "ruby",
1995
+ dependencies: spec[:dependencies] || [],
1996
+ source: spec[:source],
1997
+ checksum: spec[:checksum],
1998
+ }
1999
+ else
2000
+ {
2001
+ name: spec.name,
2002
+ version: spec.version,
2003
+ platform: spec.platform || "ruby",
2004
+ dependencies: spec.dependencies || [],
2005
+ source: spec.source,
2006
+ checksum: spec.respond_to?(:checksum) ? spec.checksum : nil,
2007
+ }
2008
+ end
2009
+ end
2010
+
2011
+ def normalize_resolved_spec(spec)
2012
+ if spec.is_a?(Hash)
2013
+ {
2014
+ name: spec[:name],
2015
+ version: spec[:version],
2016
+ platform: spec[:platform] || "ruby",
2017
+ dependencies: spec[:dependencies] || [],
2018
+ source: spec[:source],
2019
+ checksum: spec[:checksum],
2020
+ }
2021
+ else
2022
+ {
2023
+ name: spec.name,
2024
+ version: spec.version,
2025
+ platform: spec.platform || "ruby",
2026
+ dependencies: spec.dependencies || [],
2027
+ source: spec.source,
2028
+ checksum: spec.respond_to?(:checksum) ? spec.checksum : nil,
2029
+ }
2030
+ end
2031
+ end
2032
+
2033
+ def collect_lockfile_rubygems_uris(gemfile)
2034
+ uris = gemfile.sources
2035
+ .select { |src| src[:type] == :rubygems && src[:uri] }
2036
+ .map { |src| src[:uri].to_s }
2037
+
2038
+ gemfile.dependencies.each do |dep|
2039
+ inline = dep.source_options[:source]
2040
+ uris << inline.to_s if inline
2041
+ end
2042
+
2043
+ uris.uniq
2044
+ end
2045
+
2046
+ def dependency_sources_from_gemfile(gemfile, lockfile)
2047
+ existing_sources = Array(lockfile&.sources)
2048
+ out = {}
2049
+
2050
+ gemfile.dependencies.each do |dep|
2051
+ opts = dep.source_options
2052
+
2053
+ source =
2054
+ if opts[:path]
2055
+ find_matching_path_source(existing_sources, opts[:path]) ||
2056
+ Source::Path.new(path: opts[:path], name: dep.name)
2057
+ elsif opts[:git]
2058
+ matched = find_matching_git_source(existing_sources, opts)
2059
+ Source::Git.new(
2060
+ uri: opts[:git],
2061
+ revision: matched&.revision,
2062
+ ref: opts[:ref] || matched&.ref,
2063
+ branch: opts[:branch] || matched&.branch,
2064
+ tag: opts[:tag] || matched&.tag,
2065
+ submodules: opts.fetch(:submodules, matched&.submodules),
2066
+ glob: matched&.glob,
2067
+ name: dep.name,
2068
+ )
2069
+ elsif opts[:source]
2070
+ find_matching_rubygems_source(existing_sources, opts[:source]) ||
2071
+ Source::Rubygems.new(remotes: [opts[:source]])
2072
+ end
2073
+
2074
+ out[dep.name] = source if source
2075
+ end
2076
+
2077
+ out
2078
+ end
2079
+
2080
+ def lockfile_sources_by_spec_key(lockfile)
2081
+ by_full = {}
2082
+ by_name_version = {}
2083
+
2084
+ Array(lockfile&.specs).each do |spec|
2085
+ source = spec[:source]
2086
+ next unless source
2087
+
2088
+ name = spec[:name].to_s
2089
+ version = spec[:version].to_s
2090
+ platform = (spec[:platform] || "ruby").to_s
2091
+
2092
+ by_full[[name, version, platform]] = source
2093
+ by_name_version[[name, version]] ||= source
2094
+ end
2095
+
2096
+ [by_full, by_name_version]
2097
+ end
2098
+
2099
+ def source_for_spec(spec, dependency_sources:, candidate_sources:, lock_source_by_full:, lock_source_by_name_version:, fallback:)
2100
+ key_full = [spec[:name].to_s, spec[:version].to_s, spec[:platform].to_s]
2101
+ locked_source = lock_source_by_full[key_full] || lock_source_by_name_version[key_full[0, 2]]
2102
+ return locked_source if locked_source
2103
+
2104
+ dep_source = dependency_sources[spec[:name].to_s]
2105
+ return dep_source if dep_source
2106
+
2107
+ spec_source = spec[:source]
2108
+ source = find_matching_source(candidate_sources, spec_source)
2109
+ return source if source
2110
+
2111
+ spec_source = spec_source.to_s
2112
+ if git_source?(spec_source)
2113
+ source = Source::Git.new(uri: spec_source, name: spec[:name])
2114
+ candidate_sources << source
2115
+ return source
2116
+ elsif spec_source.start_with?("/") || spec_source.start_with?(".")
2117
+ source = Source::Path.new(path: spec_source, name: spec[:name])
2118
+ candidate_sources << source
2119
+ return source
2120
+ end
2121
+
2122
+ if rubygems_source_uri?(spec_source.to_s)
2123
+ source = Source::Rubygems.new(remotes: [spec_source.to_s])
2124
+ candidate_sources << source
2125
+ return source
2126
+ end
2127
+
2128
+ fallback
2129
+ end
2130
+
2131
+ def find_matching_source(sources, source_ref)
2132
+ return nil if source_ref.nil?
2133
+
2134
+ sources.find do |source|
2135
+ source_matches?(source, source_ref)
2136
+ end
2137
+ end
2138
+
2139
+ def source_matches?(source, source_ref)
2140
+ return true if source.equal?(source_ref)
2141
+ return true if source == source_ref
2142
+
2143
+ source_key = normalize_source_key(source_ref)
2144
+ return false unless source_key
2145
+
2146
+ if source.is_a?(Source::Rubygems)
2147
+ source.remotes.any? { |remote| normalize_source_key(remote) == source_key }
2148
+ elsif source.respond_to?(:uri)
2149
+ normalize_source_key(source.uri) == source_key
2150
+ else
2151
+ normalize_source_key(source) == source_key
2152
+ end
2153
+ end
2154
+
2155
+ def normalize_source_key(source_ref)
2156
+ return nil if source_ref.nil?
2157
+
2158
+ raw =
2159
+ if source_ref.respond_to?(:uri)
2160
+ source_ref.uri.to_s
2161
+ elsif source_ref.respond_to?(:path)
2162
+ source_ref.path.to_s
2163
+ else
2164
+ source_ref.to_s
2165
+ end
2166
+ return nil if raw.empty?
2167
+
2168
+ if raw.match?(%r{\Ahttps?://}i)
2169
+ raw = raw.sub(%r{\Ahttps?://}i, "")
2170
+ raw = raw.sub(%r{\.git/?\z}i, "")
2171
+ raw.chomp("/").downcase
2172
+ elsif raw.start_with?("/") || raw.start_with?(".")
2173
+ File.expand_path(raw)
2174
+ else
2175
+ raw.sub(%r{\.git/?\z}i, "").chomp("/").downcase
2176
+ end
2177
+ end
2178
+
2179
+ def find_matching_rubygems_source(sources, uri)
2180
+ sources.find do |source|
2181
+ source.is_a?(Source::Rubygems) && source.remotes.any? { |remote| source_matches?(remote, uri) }
2182
+ end
2183
+ end
2184
+
2185
+ def find_matching_path_source(sources, path)
2186
+ sources.find { |source| source.is_a?(Source::Path) && source_matches?(source, path) }
2187
+ end
2188
+
2189
+ def find_matching_git_source(sources, opts)
2190
+ candidates = sources.select { |source| source.is_a?(Source::Git) && source_matches?(source, opts[:git]) }
2191
+ return nil if candidates.empty?
2192
+
2193
+ candidates.find { |source| git_source_options_match?(source, opts) } || candidates.first
2194
+ end
2195
+
2196
+ def git_source_options_match?(source, opts)
2197
+ return false if opts[:branch] && source.branch.to_s != opts[:branch].to_s
2198
+ return false if opts[:tag] && source.tag.to_s != opts[:tag].to_s
2199
+ return false if opts[:ref] && source.ref.to_s != opts[:ref].to_s
2200
+
2201
+ true
2202
+ end
2203
+
2204
+ def uniq_sources(sources)
2205
+ out = []
2206
+ sources.each do |source|
2207
+ next unless source
2208
+ out << source unless out.any? { |existing| existing.eql?(source) }
2209
+ end
2210
+ out
2211
+ end
2212
+
2213
+ def build_lockfile_dependencies(gemfile, lockfile)
2214
+ locked = lockfile&.dependencies || {}
2215
+ gemfile.dependencies
2216
+ .select { |dep| lockfile_dependency_direct?(dep) }
2217
+ .map do |dep|
2218
+ locked_dep = locked[dep.name]
2219
+ {
2220
+ name: dep.name,
2221
+ version_reqs: dep.version_reqs,
2222
+ pinned: !!(locked_dep && locked_dep[:pinned]),
2223
+ }
2224
+ end
2225
+ end
2226
+
2227
+ def lockfile_dependency_direct?(dep)
2228
+ opts = dep.source_options || {}
2229
+ return true unless opts[:gemspec_generated]
2230
+
2231
+ opts[:gemspec_primary] != false
2232
+ end
2233
+
2234
+ def build_lockfile_platforms(specs, lockfile)
2235
+ platforms = Set.new(Array(lockfile&.platforms))
2236
+ specs.each do |spec|
2237
+ platform = spec[:platform] || "ruby"
2238
+ platforms << platform
2239
+ end
2240
+ platforms << "ruby"
2241
+ platforms.to_a
2242
+ end
2243
+
2244
+ def build_lockfile_checksums(specs, lockfile)
2245
+ existing = lockfile&.checksums
2246
+ checksums = {}
2247
+
2248
+ specs.each do |spec|
2249
+ key = lockfile_spec_checksum_key(spec)
2250
+ checksum = spec[:checksum]
2251
+ if checksum && !Array(checksum).empty?
2252
+ checksums[key] = Array(checksum)
2253
+ elsif existing&.key?(key)
2254
+ checksums[key] = Array(existing[key])
2255
+ end
2256
+ end
2257
+
2258
+ return nil if checksums.empty?
2259
+
2260
+ checksums
2261
+ end
2262
+
2263
+ def lockfile_spec_checksum_key(spec)
2264
+ SpecUtils.full_name_for(spec[:name], spec[:version], spec[:platform] || "ruby")
2265
+ end
2266
+
1055
2267
  def write_runtime_config(resolved, bundle_path)
1056
- ruby_dir = File.join(bundle_path, "ruby",
1057
- RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
2268
+ ruby_dir = Platform.ruby_install_dir(bundle_path)
1058
2269
 
1059
2270
  data = {}
1060
2271
  resolved.each do |spec|
1061
- full = spec_full_name(spec)
2272
+ full = SpecUtils.full_name(spec)
1062
2273
  gem_dir = File.join(ruby_dir, "gems", full)
1063
2274
  spec_file = File.join(ruby_dir, "specifications", "#{full}.gemspec")
1064
2275
  require_paths = read_require_paths(spec_file)
1065
2276
  load_paths = require_paths
1066
- .map { |rp| File.join(gem_dir, rp) }
2277
+ .map { |rp| expand_require_path(gem_dir, rp) }
1067
2278
  .select { |path| Dir.exist?(path) }
1068
2279
 
1069
2280
  default_lib = File.join(gem_dir, "lib")
1070
2281
  load_paths << default_lib if load_paths.empty? && Dir.exist?(default_lib)
1071
- load_paths.concat(detect_nested_lib_paths(gem_dir))
1072
2282
  load_paths.uniq!
1073
2283
 
1074
2284
  # Add ext load path if extensions exist
@@ -1076,6 +2286,12 @@ module Scint
1076
2286
  Platform.gem_arch, Platform.extension_api_version, full)
1077
2287
  load_paths << ext_dir if Dir.exist?(ext_dir)
1078
2288
 
2289
+ if load_paths.empty?
2290
+ source_paths = runtime_source_load_paths(spec)
2291
+ load_paths.concat(source_paths)
2292
+ load_paths.uniq!
2293
+ end
2294
+
1079
2295
  data[spec.name] = {
1080
2296
  version: spec.version.to_s,
1081
2297
  load_paths: load_paths,
@@ -1089,33 +2305,47 @@ module Scint
1089
2305
  def read_require_paths(spec_file)
1090
2306
  return ["lib"] unless File.exist?(spec_file)
1091
2307
 
1092
- gemspec = Gem::Specification.load(spec_file)
2308
+ gemspec = SpecUtils.load_gemspec(spec_file)
1093
2309
  paths = Array(gemspec&.require_paths).reject(&:empty?)
1094
2310
  paths.empty? ? ["lib"] : paths
1095
- rescue StandardError
2311
+ rescue SystemExit, StandardError
1096
2312
  ["lib"]
1097
2313
  end
1098
2314
 
1099
- def detect_nested_lib_paths(gem_dir)
1100
- lib_dir = File.join(gem_dir, "lib")
1101
- return [] unless Dir.exist?(lib_dir)
2315
+ def expand_require_path(gem_dir, require_path)
2316
+ value = require_path.to_s
2317
+ return value if Pathname.new(value).absolute?
1102
2318
 
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")
2319
+ File.join(gem_dir, value)
2320
+ rescue StandardError
2321
+ File.join(gem_dir, require_path.to_s)
2322
+ end
2323
+
2324
+ def runtime_source_load_paths(spec)
2325
+ source_root = spec.source.to_s
2326
+ return [] unless source_root.start_with?("/") && Dir.exist?(source_root)
2327
+
2328
+ source_dir = begin
2329
+ resolve_git_gem_subdir(source_root, spec)
2330
+ rescue InstallError
2331
+ source_root
1107
2332
  end
1108
- return [] if top_level_rb
1109
2333
 
1110
- children
1111
- .map { |entry| File.join(lib_dir, entry) }
1112
- .select { |path| File.directory?(path) }
2334
+ gemspec_file = File.join(source_dir, "#{spec.name}.gemspec")
2335
+ require_paths = read_require_paths(gemspec_file)
2336
+ paths = require_paths
2337
+ .map { |rp| expand_require_path(source_dir, rp) }
2338
+ .select { |path| Dir.exist?(path) }
2339
+
2340
+ default_lib = File.join(source_dir, "lib")
2341
+ paths << default_lib if paths.empty? && Dir.exist?(default_lib)
2342
+ paths.uniq
2343
+ rescue StandardError
2344
+ []
1113
2345
  end
1114
2346
 
1115
2347
  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}"
2348
+ SpecUtils.full_name(spec)
1119
2349
  end
1120
2350
 
1121
2351
  def elapsed_ms_since(start_time)
@@ -1124,7 +2354,7 @@ module Scint
1124
2354
  end
1125
2355
 
1126
2356
  def force_purge_artifacts(resolved, bundle_path, cache)
1127
- ruby_dir = File.join(bundle_path, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
2357
+ ruby_dir = Platform.ruby_install_dir(bundle_path)
1128
2358
  ext_root = File.join(ruby_dir, "extensions", Platform.gem_arch, Platform.extension_api_version)
1129
2359
 
1130
2360
  resolved.each do |spec|
@@ -1132,8 +2362,12 @@ module Scint
1132
2362
 
1133
2363
  # Global cache artifacts.
1134
2364
  FileUtils.rm_f(cache.inbound_path(spec))
1135
- FileUtils.rm_rf(cache.extracted_path(spec))
2365
+ FileUtils.rm_rf(cache.assembling_path(spec))
2366
+ FileUtils.rm_rf(cache.cached_path(spec))
2367
+ FileUtils.rm_f(cache.cached_spec_path(spec))
2368
+ FileUtils.rm_f(cache.cached_manifest_path(spec))
1136
2369
  FileUtils.rm_f(cache.spec_cache_path(spec))
2370
+ FileUtils.rm_rf(cache.extracted_path(spec))
1137
2371
  FileUtils.rm_rf(cache.ext_path(spec))
1138
2372
 
1139
2373
  # Local bundle artifacts.
@@ -1160,6 +2394,28 @@ module Scint
1160
2394
  "#{format_elapsed(elapsed_ms)}, #{workers} #{noun} used"
1161
2395
  end
1162
2396
 
2397
+ def emit_network_error_details(error)
2398
+ return unless error.is_a?(NetworkError)
2399
+
2400
+ headers = error.response_headers
2401
+ body = error.response_body.to_s
2402
+ return if (headers.nil? || headers.empty?) && body.empty?
2403
+
2404
+ if headers && !headers.empty?
2405
+ $stderr.puts " headers:"
2406
+ headers.sort.each do |key, value|
2407
+ $stderr.puts " #{key}: #{value}"
2408
+ end
2409
+ end
2410
+
2411
+ return if body.empty?
2412
+
2413
+ $stderr.puts " body:"
2414
+ body.each_line do |line|
2415
+ $stderr.puts " #{line.rstrip}"
2416
+ end
2417
+ end
2418
+
1163
2419
  def warn_missing_bundle_gitignore_entry
1164
2420
  path = ".gitignore"
1165
2421
  return unless File.file?(path)
@@ -1201,6 +2457,12 @@ module Scint
1201
2457
  when "--path"
1202
2458
  @path = @argv[i + 1]
1203
2459
  i += 2
2460
+ when "--without"
2461
+ @without_groups = @argv[i + 1]&.split(/[\s:,]+/)&.map(&:to_sym) || []
2462
+ i += 2
2463
+ when "--with"
2464
+ @with_groups = @argv[i + 1]&.split(/[\s:,]+/)&.map(&:to_sym) || []
2465
+ i += 2
1204
2466
  when "--verbose"
1205
2467
  @verbose = true
1206
2468
  i += 1
@@ -1211,6 +2473,32 @@ module Scint
1211
2473
  i += 1
1212
2474
  end
1213
2475
  end
2476
+
2477
+ # Also read BUNDLE_WITHOUT / BUNDLE_WITH env vars (Bundler compat)
2478
+ if !@without_groups && ENV["BUNDLE_WITHOUT"]
2479
+ @without_groups = ENV["BUNDLE_WITHOUT"].split(/[\s:,]+/).map(&:to_sym)
2480
+ end
2481
+ if !@with_groups && ENV["BUNDLE_WITH"]
2482
+ @with_groups = ENV["BUNDLE_WITH"].split(/[\s:,]+/).map(&:to_sym)
2483
+ end
2484
+
2485
+ # Read from .bundle/config if present
2486
+ load_bundle_config_groups if !@without_groups && !@with_groups
2487
+ end
2488
+
2489
+ def load_bundle_config_groups
2490
+ config_path = File.join(".bundle", "config")
2491
+ return unless File.exist?(config_path)
2492
+
2493
+ config = YAML.safe_load(File.read(config_path)) rescue nil
2494
+ return unless config.is_a?(Hash)
2495
+
2496
+ if config["BUNDLE_WITHOUT"] && !@without_groups
2497
+ @without_groups = config["BUNDLE_WITHOUT"].to_s.split(/[\s:]+/).map(&:to_sym)
2498
+ end
2499
+ if config["BUNDLE_WITH"] && !@with_groups
2500
+ @with_groups = config["BUNDLE_WITH"].to_s.split(/[\s:]+/).map(&:to_sym)
2501
+ end
1214
2502
  end
1215
2503
  end
1216
2504
  end