scint 0.7.0 → 0.8.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.
@@ -23,7 +23,9 @@ require_relative "../downloader/pool"
23
23
  require_relative "../gem/package"
24
24
  require_relative "../gem/extractor"
25
25
  require_relative "../cache/layout"
26
+ require_relative "../cache/manifest"
26
27
  require_relative "../cache/metadata_store"
28
+ require_relative "../cache/validity"
27
29
  require_relative "../installer/planner"
28
30
  require_relative "../installer/linker"
29
31
  require_relative "../installer/preparer"
@@ -41,40 +43,57 @@ module Scint
41
43
  class Install
42
44
  RUNTIME_LOCK = "scint.lock.marshal"
43
45
 
44
- def initialize(argv = [])
46
+ def initialize(argv = [], without: nil, with: nil, output: $stderr)
45
47
  @argv = argv
46
48
  @jobs = nil
47
49
  @path = nil
48
50
  @verbose = false
49
51
  @force = false
52
+ @without_groups = nil
53
+ @with_groups = nil
54
+ @output = output
50
55
  @download_pool = nil
51
56
  @download_pool_lock = Thread::Mutex.new
52
57
  @gemspec_cache = {}
53
58
  @gemspec_cache_lock = Thread::Mutex.new
54
59
  parse_options
60
+ # Allow programmatic override (for tests)
61
+ @without_groups = Array(without).map(&:to_sym) if without
62
+ @with_groups = Array(with).map(&:to_sym) if with
63
+ end
64
+
65
+ def _tmark(label, t0)
66
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+ @output.puts " [timing] #{label}: #{((now - t0) * 1000).round}ms" if ENV["SCINT_TIMING"]
68
+ now
55
69
  end
56
70
 
57
71
  def run
58
72
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
73
+ _t = start_time
59
74
 
60
75
  cache = Scint::Cache::Layout.new
76
+ cache_telemetry = Scint::Cache::Telemetry.new
61
77
  bundle_path = @path || ENV["BUNDLER_PATH"] || ".bundle"
62
78
  bundle_display = display_bundle_path(bundle_path)
63
79
  bundle_path = File.expand_path(bundle_path)
64
- worker_count = @jobs || [Platform.cpu_count * 2, 50].min
80
+ worker_count = [(@jobs || [Platform.cpu_count * 2, 50].min).to_i, 1].max
65
81
  compile_slots = compile_slots_for(worker_count)
66
- per_type_limits = install_task_limits(worker_count, compile_slots)
67
- $stdout.puts "#{GREEN}💎#{RESET} Scintellating Gemfile into #{BOLD}#{bundle_display}#{RESET} #{DIM}(scint #{VERSION}, ruby #{RUBY_VERSION})#{RESET}"
68
- $stdout.puts
82
+ git_slots = git_slots_for(worker_count)
83
+ per_type_limits = install_task_limits(worker_count, compile_slots, git_slots)
84
+ @output.puts "#{GREEN}💎#{RESET} Scintellating Gemfile into #{BOLD}#{bundle_display}#{RESET} #{DIM}(scint #{VERSION}, ruby #{RUBY_VERSION})#{RESET}"
85
+ @output.puts
69
86
 
70
87
  # 0. Build credential store from config files (~/.bundle/config, XDG scint/credentials)
71
88
  @credentials = Credentials.new
72
89
 
73
90
  # 1. Start the scheduler with 1 worker — scale up dynamically
74
- scheduler = Scheduler.new(max_workers: worker_count, fail_fast: true, per_type_limits: per_type_limits)
91
+ scheduler = Scheduler.new(max_workers: worker_count, fail_fast: true, per_type_limits: per_type_limits,
92
+ progress: Progress.new(output: @output))
75
93
  scheduler.start
76
94
 
77
95
  begin
96
+ _t = _tmark("startup", _t)
78
97
  # 2. Parse Gemfile
79
98
  gemfile = Scint::Gemfile::Parser.parse("Gemfile")
80
99
 
@@ -86,6 +105,7 @@ module Scint
86
105
  dep_count = gemfile.dependencies.size
87
106
  scheduler.scale_workers(dep_count)
88
107
 
108
+ _t = _tmark("parse_gemfile", _t)
89
109
  # 3. Enqueue index fetches for all sources immediately
90
110
  gemfile.sources.each do |source|
91
111
  scheduler.enqueue(:fetch_index, source[:uri] || source.to_s,
@@ -106,20 +126,26 @@ module Scint
106
126
  -> { clone_git_source(source, cache) })
107
127
  end
108
128
 
129
+ _t = _tmark("enqueue_fetches", _t)
109
130
  # 6. Wait for index fetches, then resolve
110
131
  scheduler.wait_for(:fetch_index)
132
+ _t = _tmark("wait_index", _t)
111
133
  scheduler.wait_for(:git_clone)
134
+ _t = _tmark("wait_git", _t)
112
135
 
113
136
  resolved = resolve(gemfile, lockfile, cache)
114
137
  resolved = dedupe_resolved_specs(adjust_meta_gems(resolved))
138
+ resolved = filter_excluded_gems(resolved, gemfile)
115
139
  force_purge_artifacts(resolved, bundle_path, cache) if @force
116
140
 
141
+ _t = _tmark("resolve", _t)
117
142
  # 7. Plan: diff resolved vs installed
118
- plan = Installer::Planner.plan(resolved, bundle_path, cache)
143
+ plan = Installer::Planner.plan(resolved, bundle_path, cache, telemetry: cache_telemetry)
119
144
  total_gems = resolved.size
120
145
  updated_gems = plan.count { |e| e.action != :skip }
121
146
  cached_gems = total_gems - updated_gems
122
147
  to_install = plan.reject { |e| e.action == :skip }
148
+ _t = _tmark("plan", _t)
123
149
 
124
150
  # Scale up for download/install phase based on actual work count
125
151
  scheduler.scale_workers(to_install.size)
@@ -127,6 +153,7 @@ module Scint
127
153
  # Warm-cache accelerator: pre-materialize cache-backed gem trees in
128
154
  # batches so install workers avoid one cp process per gem.
129
155
  bulk_prelink_gem_files(to_install, cache, bundle_path)
156
+ _t = _tmark("prelink", _t)
130
157
 
131
158
  if to_install.empty?
132
159
  # Keep lock artifacts aligned even when everything is already installed.
@@ -135,7 +162,7 @@ module Scint
135
162
  elapsed_ms = elapsed_ms_since(start_time)
136
163
  worker_count = scheduler.stats[:workers]
137
164
  warn_missing_bundle_gitignore_entry
138
- $stdout.puts "\n#{GREEN}#{total_gems}#{RESET} gems installed total#{install_breakdown(cached: cached_gems, updated: updated_gems)}. #{DIM}(#{format_run_footer(elapsed_ms, worker_count)})#{RESET}"
165
+ @output.puts "\n#{GREEN}#{total_gems}#{RESET} gems installed total#{install_breakdown(cached: cached_gems, updated: updated_gems)}. #{DIM}(#{format_run_footer(elapsed_ms, worker_count)})#{RESET}"
139
166
  return 0
140
167
  end
141
168
 
@@ -160,14 +187,14 @@ module Scint
160
187
  errors = scheduler.errors.dup
161
188
  stats = scheduler.stats
162
189
  if errors.any?
163
- $stderr.puts "#{RED}Some gems failed to install:#{RESET}"
190
+ @output.puts "#{RED}Some gems failed to install:#{RESET}"
164
191
  errors.each do |err|
165
192
  error = err[:error]
166
- $stderr.puts " #{BOLD}#{err[:name]}#{RESET}: #{error.message}"
193
+ @output.puts " #{BOLD}#{err[:name]}#{RESET}: #{error.message}"
167
194
  emit_network_error_details(error)
168
195
  end
169
196
  elsif stats[:failed] > 0
170
- $stderr.puts "#{YELLOW}Warning: #{stats[:failed]} jobs failed but no error details captured#{RESET}"
197
+ @output.puts "#{YELLOW}Warning: #{stats[:failed]} jobs failed but no error details captured#{RESET}"
171
198
  end
172
199
 
173
200
  elapsed_ms = elapsed_ms_since(start_time)
@@ -180,21 +207,25 @@ module Scint
180
207
 
181
208
  if has_failures
182
209
  warn_missing_bundle_gitignore_entry
183
- $stdout.puts "\n#{RED}Bundle failed!#{RESET} #{installed_total}/#{total_gems} gems installed total#{install_breakdown(cached: cached_gems, updated: updated_gems, compiled: compiled_gems, failed: failed_count)}. #{DIM}(#{format_run_footer(elapsed_ms, worker_count)})#{RESET}"
210
+ @output.puts "\n#{RED}Bundle failed!#{RESET} #{installed_total}/#{total_gems} gems installed total#{install_breakdown(cached: cached_gems, updated: updated_gems, compiled: compiled_gems, failed: failed_count)}. #{DIM}(#{format_run_footer(elapsed_ms, worker_count)})#{RESET}"
184
211
  1
185
212
  else
186
213
  # 10. Write lockfile + runtime config only for successful installs
187
214
  write_lockfile(resolved, gemfile, lockfile)
188
215
  write_runtime_config(resolved, bundle_path)
189
216
  warn_missing_bundle_gitignore_entry
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}"
217
+ @output.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}"
191
218
  0
192
219
  end
193
220
  ensure
194
221
  begin
195
- scheduler.shutdown
222
+ cache_telemetry.warn_if_needed(cache_root: cache.root)
196
223
  ensure
197
- close_download_pool
224
+ begin
225
+ scheduler.shutdown
226
+ ensure
227
+ close_download_pool
228
+ end
198
229
  end
199
230
  end
200
231
  end
@@ -233,6 +264,91 @@ module Scint
233
264
  seen.values
234
265
  end
235
266
 
267
+ # Determine which gem names should be excluded based on group settings.
268
+ # A gem is excluded if ALL of its group memberships are in excluded groups.
269
+ # Gems appearing in any non-excluded group are kept.
270
+ def excluded_gem_names(gemfile, resolved: nil)
271
+ excluded_groups = compute_excluded_groups(gemfile)
272
+ return Set.new if excluded_groups.empty?
273
+
274
+ # Build map: gem_name => set of all groups it appears in (across all declarations)
275
+ gem_groups = Hash.new { |h, k| h[k] = Set.new }
276
+ gemfile.dependencies.each do |dep|
277
+ dep.groups.each { |g| gem_groups[dep.name] << g }
278
+ end
279
+
280
+ # A gem is directly excluded if ALL its groups are excluded
281
+ directly_excluded = Set.new
282
+ gem_groups.each do |name, groups|
283
+ directly_excluded << name if groups.subset?(excluded_groups)
284
+ end
285
+
286
+ # If we have resolved specs, also exclude transitive-only deps
287
+ if resolved && directly_excluded.any?
288
+ exclude_transitive_deps(directly_excluded, resolved, gem_groups)
289
+ else
290
+ directly_excluded
291
+ end
292
+ end
293
+
294
+ # Filter resolved specs, removing gems that belong only to excluded groups.
295
+ def filter_excluded_gems(resolved, gemfile)
296
+ excluded = excluded_gem_names(gemfile, resolved: resolved)
297
+ return resolved if excluded.empty?
298
+
299
+ resolved.reject { |spec| excluded.include?(spec.name) }
300
+ end
301
+
302
+ private
303
+
304
+ def compute_excluded_groups(gemfile)
305
+ optional = Set.new(Array(gemfile.optional_groups))
306
+ without = Set.new(Array(@without_groups))
307
+ with = Set.new(Array(@with_groups))
308
+
309
+ # Optional groups are excluded by default unless explicitly included via --with
310
+ excluded = optional - with
311
+ # --without adds more groups to exclude
312
+ excluded.merge(without)
313
+ excluded
314
+ end
315
+
316
+ # Walk the dependency graph to find transitive deps that are ONLY
317
+ # reachable through excluded gems. Shared deps are kept.
318
+ def exclude_transitive_deps(directly_excluded, resolved, gem_groups)
319
+ # Build dependency graph: name => [dep_names]
320
+ dep_graph = {}
321
+ resolved.each do |spec|
322
+ dep_names = Array(spec.dependencies).filter_map do |dep|
323
+ if dep.is_a?(Hash)
324
+ dep[:name] || dep["name"]
325
+ elsif dep.respond_to?(:name)
326
+ dep.name
327
+ end
328
+ end
329
+ dep_graph[spec.name] = dep_names
330
+ end
331
+
332
+ all_names = Set.new(resolved.map(&:name))
333
+
334
+ # Start from Gemfile deps that are NOT excluded, then walk transitive deps
335
+ included_roots = gem_groups.keys.reject { |n| directly_excluded.include?(n) }
336
+
337
+ # BFS from included roots to find all reachable gems
338
+ reachable = Set.new
339
+ queue = included_roots.dup
340
+ while (name = queue.shift)
341
+ next if reachable.include?(name)
342
+ reachable << name
343
+ (dep_graph[name] || []).each { |dep| queue << dep }
344
+ end
345
+
346
+ # Everything not reachable from included roots is excluded
347
+ all_names - reachable
348
+ end
349
+
350
+ public
351
+
236
352
  # Install scint into the bundle by copying our own lib tree.
237
353
  # No download needed — we know exactly where we are.
238
354
  def install_builtin_gem(entry, bundle_path)
@@ -354,20 +470,20 @@ module Scint
354
470
  if opts[:git]
355
471
  git_source = find_matching_git_source(Array(lockfile&.sources), opts) || find_matching_git_source(gemfile.sources, opts)
356
472
  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)
473
+ git_repo = cache&.git_path(opts[:git])
474
+ if git_repo && !Dir.exist?(git_repo)
475
+ clone_git_repo(opts[:git], git_repo)
476
+ elsif git_repo && Dir.exist?(git_repo)
477
+ fetch_git_repo(git_repo)
362
478
  end
363
- if bare_repo && Dir.exist?(bare_repo)
479
+ if git_repo && Dir.exist?(git_repo)
364
480
  begin
365
- resolved_revision = resolve_git_revision(bare_repo, revision_hint)
481
+ resolved_revision = resolve_git_revision(git_repo, revision_hint)
366
482
  cache_key = "#{opts[:git]}@#{resolved_revision}"
367
483
  git_metadata = git_source_metadata_cache[cache_key]
368
484
  unless git_metadata
369
485
  git_metadata = build_git_path_gems_for_revision(
370
- bare_repo,
486
+ git_repo,
371
487
  resolved_revision,
372
488
  glob: opts[:glob],
373
489
  source_desc: opts[:git],
@@ -429,17 +545,17 @@ module Scint
429
545
  candidates.each do |gs|
430
546
  next unless File.exist?(gs)
431
547
  begin
432
- spec = Gem::Specification.load(gs)
548
+ spec = SpecUtils.load_gemspec(gs, isolate: true)
433
549
  return spec if spec
434
- rescue StandardError
550
+ rescue SystemExit, StandardError
435
551
  nil
436
552
  end
437
553
  end
438
554
  nil
439
555
  end
440
556
 
441
- def find_git_gemspec(bare_repo, revision, gem_name, glob: nil)
442
- gemspec_paths = gemspec_paths_in_git_revision(bare_repo, revision)
557
+ def find_git_gemspec(git_repo, revision, gem_name, glob: nil)
558
+ gemspec_paths = gemspec_paths_in_git_revision(git_repo, revision)
443
559
  return nil if gemspec_paths.empty?
444
560
 
445
561
  path = gemspec_paths[gem_name.to_s]
@@ -450,23 +566,23 @@ module Scint
450
566
  path ||= gemspec_paths.values.first
451
567
  return nil if path.nil?
452
568
 
453
- load_git_gemspec(bare_repo, revision, path)
569
+ load_git_gemspec(git_repo, revision, path)
454
570
  rescue StandardError
455
571
  nil
456
572
  end
457
573
 
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)
574
+ def build_git_path_gems_for_revision(git_repo, revision, glob: nil, source_desc: nil)
575
+ gemspec_paths = gemspec_paths_in_git_revision(git_repo, revision)
460
576
  return {} if gemspec_paths.empty?
461
577
 
462
578
  glob_regex = glob ? git_glob_to_regex(glob) : nil
463
579
  data = {}
464
580
 
465
- with_git_worktree(bare_repo, revision) do |worktree|
581
+ with_git_checkout(git_repo, revision) do |checkout_dir|
466
582
  gemspec_paths.each_value do |path|
467
583
  next if glob_regex && !path.match?(glob_regex)
468
584
 
469
- gemspec = load_gemspec_from_worktree(worktree, path)
585
+ gemspec = load_gemspec_from_checkout(checkout_dir, path)
470
586
  next unless gemspec
471
587
 
472
588
  deps = gemspec.dependencies
@@ -611,19 +727,19 @@ module Scint
611
727
  by_source = git_specs.group_by { |s| s[:source] }
612
728
  by_source.each do |source, specs|
613
729
  uri, revision = git_source_ref(source)
614
- bare_repo = cache.git_path(uri)
730
+ git_repo = cache.git_path(uri)
615
731
  # Do not invalidate an otherwise-usable lockfile just because this
616
732
  # git source has not been cached yet in the current machine/session.
617
- next unless Dir.exist?(bare_repo)
733
+ next unless Dir.exist?(git_repo)
618
734
 
619
735
  resolved_revision = begin
620
- resolve_git_revision(bare_repo, revision)
736
+ resolve_git_revision(git_repo, revision)
621
737
  rescue InstallError
622
738
  nil
623
739
  end
624
740
  return false unless resolved_revision
625
741
 
626
- gemspec_paths = gemspec_paths_in_git_revision(bare_repo, resolved_revision)
742
+ gemspec_paths = gemspec_paths_in_git_revision(git_repo, resolved_revision)
627
743
  gemspec_names = gemspec_paths.keys.to_set
628
744
  return false if gemspec_names.empty?
629
745
 
@@ -635,9 +751,9 @@ module Scint
635
751
  true
636
752
  end
637
753
 
638
- def gemspec_paths_in_git_revision(bare_repo, revision)
754
+ def gemspec_paths_in_git_revision(git_repo, revision)
639
755
  out, _err, status = git_capture3(
640
- "--git-dir", bare_repo,
756
+ "-C", git_repo,
641
757
  "ls-tree",
642
758
  "-r",
643
759
  "--name-only",
@@ -657,8 +773,8 @@ module Scint
657
773
  {}
658
774
  end
659
775
 
660
- def runtime_dependencies_for_git_gemspec(bare_repo, revision, gemspec_path)
661
- spec = load_git_gemspec(bare_repo, revision, gemspec_path)
776
+ def runtime_dependencies_for_git_gemspec(git_repo, revision, gemspec_path)
777
+ spec = load_git_gemspec(git_repo, revision, gemspec_path)
662
778
  return nil unless spec
663
779
 
664
780
  spec.dependencies.select { |dep| dep.type == :runtime }
@@ -666,41 +782,32 @@ module Scint
666
782
  nil
667
783
  end
668
784
 
669
- def load_git_gemspec(bare_repo, revision, gemspec_path)
785
+ def load_git_gemspec(git_repo, revision, gemspec_path)
670
786
  return nil if gemspec_path.to_s.empty?
671
787
 
672
- with_git_worktree(bare_repo, revision) do |worktree|
673
- load_gemspec_from_worktree(worktree, gemspec_path)
788
+ with_git_checkout(git_repo, revision) do |checkout_dir|
789
+ load_gemspec_from_checkout(checkout_dir, gemspec_path)
674
790
  end
675
791
  rescue StandardError
676
792
  nil
677
793
  end
678
794
 
679
- def with_git_worktree(bare_repo, revision)
680
- worktree = Dir.mktmpdir("scint-gemspec")
795
+ def with_git_checkout(git_repo, revision)
681
796
  _out, _err, status = git_capture3(
682
- "--git-dir", bare_repo,
683
- "--work-tree", worktree,
684
- "checkout",
685
- "--force",
686
- revision,
797
+ "-C", git_repo,
798
+ "checkout", "-f", revision,
687
799
  )
688
800
  return nil unless status.success?
689
801
 
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?
802
+ yield git_repo if block_given?
694
803
  end
695
804
 
696
- def load_gemspec_from_worktree(worktree, gemspec_path)
697
- absolute_gemspec = File.join(worktree, gemspec_path)
805
+ def load_gemspec_from_checkout(checkout_dir, gemspec_path)
806
+ absolute_gemspec = File.join(checkout_dir, gemspec_path)
698
807
  return nil unless File.exist?(absolute_gemspec)
699
808
 
700
- Dir.chdir(File.dirname(absolute_gemspec)) do
701
- Gem::Specification.load(absolute_gemspec)
702
- end
703
- rescue StandardError
809
+ SpecUtils.load_gemspec(absolute_gemspec, isolate: true)
810
+ rescue SystemExit, StandardError
704
811
  nil
705
812
  end
706
813
 
@@ -786,7 +893,7 @@ module Scint
786
893
  spec = entry.spec
787
894
  source = spec.source
788
895
  if git_source?(source)
789
- prepare_git_checkout(spec, cache, fetch: false)
896
+ ensure_git_repo_for_spec(spec, cache, fetch: true)
790
897
  return
791
898
  end
792
899
  source_uri = source.to_s
@@ -827,21 +934,34 @@ module Scint
827
934
  # Git gems are extracted from the cached checkout; path gems are
828
935
  # linked directly from local source.
829
936
  if git_source?(spec.source)
830
- materialize_git_spec(entry, cache)
937
+ assemble_git_spec(entry, cache, fetch: false)
831
938
  return
832
939
  end
833
940
  return if source_uri.start_with?("/") || !source_uri.start_with?("http")
834
941
 
835
- extracted = cache.extracted_path(spec)
836
- return if Dir.exist?(extracted)
942
+ return if Scint::Cache::Validity.cached_valid?(spec, cache)
837
943
 
838
944
  dest_path = cache.inbound_path(spec)
839
945
  raise InstallError, "Missing cached gem file for #{spec.name}: #{dest_path}" unless File.exist?(dest_path)
840
946
 
841
- FS.mkdir_p(extracted)
842
- pkg = GemPkg::Package.new
843
- result = pkg.extract(dest_path, extracted)
844
- cache_gemspec(spec, result[:gemspec], cache)
947
+ assembling = cache.assembling_path(spec)
948
+ tmp = "#{assembling}.#{Process.pid}.#{Thread.current.object_id}.tmp"
949
+ begin
950
+ FileUtils.rm_rf(assembling)
951
+ FileUtils.rm_rf(tmp)
952
+ FS.mkdir_p(File.dirname(assembling))
953
+
954
+ pkg = GemPkg::Package.new
955
+ result = pkg.extract(dest_path, tmp)
956
+ FS.atomic_move(tmp, assembling)
957
+ cache_gemspec(spec, result[:gemspec], cache)
958
+
959
+ unless Installer::ExtensionBuilder.needs_build?(spec, assembling)
960
+ promote_assembled_gem(spec, cache, assembling, result[:gemspec], extensions: false)
961
+ end
962
+ ensure
963
+ FileUtils.rm_rf(tmp) if tmp && File.exist?(tmp)
964
+ end
845
965
  end
846
966
 
847
967
  def git_source?(source)
@@ -870,113 +990,94 @@ module Scint
870
990
  def prepare_git_source(entry, cache)
871
991
  # Legacy helper used by tests/callers that expect git download+extract
872
992
  # in a single step.
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)
993
+ assemble_git_spec(entry, cache, fetch: true)
876
994
  end
877
995
 
878
- def prepare_git_checkout(spec, cache, fetch: false)
996
+ def ensure_git_repo_for_spec(spec, cache, fetch:)
879
997
  source = spec.source
880
- uri, revision = git_source_ref(source)
881
- submodules = git_source_submodules?(source)
882
-
883
- bare_repo = cache.git_path(uri)
998
+ uri, _revision = git_source_ref(source)
999
+ git_repo = cache.git_path(uri)
884
1000
 
885
- # Serialize all git operations per bare repo — git uses index.lock
1001
+ # Serialize all git operations per repo — git uses index.lock
886
1002
  # and can't handle concurrent checkouts from the same repo.
887
- git_mutex_for(bare_repo).synchronize do
888
- if Dir.exist?(bare_repo)
889
- fetch_git_repo(bare_repo) if fetch
1003
+ git_mutex_for(git_repo).synchronize do
1004
+ if Dir.exist?(git_repo)
1005
+ fetch_git_repo(git_repo) if fetch
890
1006
  else
891
- clone_git_repo(uri, bare_repo)
892
- fetch_git_repo(bare_repo)
1007
+ clone_git_repo(uri, git_repo)
893
1008
  end
894
-
895
- resolved_revision = resolve_git_revision(bare_repo, revision)
896
- incoming_checkout = cache.git_checkout_path(uri, resolved_revision)
897
- materialize_git_checkout(
898
- bare_repo,
899
- incoming_checkout,
900
- resolved_revision,
901
- spec,
902
- uri,
903
- submodules: submodules,
904
- )
905
- [incoming_checkout, resolved_revision, uri]
906
1009
  end
1010
+
1011
+ git_repo
907
1012
  end
908
1013
 
909
- def materialize_git_spec(entry, cache, checkout_dir: nil, resolved_revision: nil)
1014
+ def assemble_git_spec(entry, cache, fetch: true)
910
1015
  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
1016
+ return if Scint::Cache::Validity.cached_valid?(spec, cache)
921
1017
 
922
- tmp = "#{extracted}.#{Process.pid}.#{Thread.current.object_id}.tmp"
923
- begin
924
- FileUtils.rm_rf(tmp)
925
- FS.clone_tree(gem_root, tmp)
1018
+ source = spec.source
1019
+ uri, revision = git_source_ref(source)
1020
+ submodules = git_source_submodules?(source)
926
1021
 
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
1022
+ git_repo = cache.git_path(uri)
934
1023
 
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
1024
+ # Serialize all git operations per repo — git uses index.lock
1025
+ # and can't handle concurrent checkouts from the same repo.
1026
+ git_mutex_for(git_repo).synchronize do
1027
+ tmp_assembled = nil
940
1028
 
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]
1029
+ begin
1030
+ if Dir.exist?(git_repo)
1031
+ fetch_git_repo(git_repo) if fetch
1032
+ else
1033
+ clone_git_repo(uri, git_repo)
1034
+ end
947
1035
 
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
1036
+ resolved_revision = resolve_git_revision(git_repo, revision)
1037
+ assembling = cache.assembling_path(spec)
1038
+ tmp_assembled = "#{assembling}.#{Process.pid}.#{Thread.current.object_id}.tmp"
1039
+ promoter = cache_promoter(cache)
953
1040
 
954
- tmp = "#{checkout_dir}.#{Process.pid}.#{Thread.current.object_id}.tmp"
955
- begin
956
- FileUtils.rm_rf(tmp)
957
- if submodules
958
- checkout_git_tree_with_submodules(
959
- bare_repo,
960
- tmp,
961
- resolved_revision,
962
- spec,
963
- uri,
964
- )
965
- else
966
- checkout_git_tree(
967
- bare_repo,
968
- tmp,
969
- resolved_revision,
970
- spec,
971
- uri,
972
- )
973
- end
1041
+ FileUtils.rm_rf(assembling)
1042
+ FileUtils.rm_rf(tmp_assembled)
1043
+ FS.mkdir_p(File.dirname(assembling))
974
1044
 
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)
1045
+ promoter.validate_within_root!(cache.root, assembling, label: "assembling")
1046
+ promoter.validate_within_root!(cache.root, tmp_assembled, label: "git-assembled")
1047
+
1048
+ checkout_git_revision(git_repo, resolved_revision, spec, uri, submodules: submodules)
1049
+
1050
+ gem_root = resolve_git_gem_subdir(git_repo, spec)
1051
+ gem_rel = git_relative_root(git_repo, gem_root)
1052
+ dest_root = tmp_assembled
1053
+ dest_path = gem_rel.empty? ? dest_root : File.join(dest_root, gem_rel)
1054
+
1055
+ promoter.validate_within_root!(cache.root, dest_path, label: "git-dest")
1056
+
1057
+ FS.clone_tree(gem_root, dest_path)
1058
+
1059
+ # Remove .git artifacts so assembled output is deterministic.
1060
+ Dir.glob(File.join(tmp_assembled, "**", ".git"), File::FNM_DOTMATCH).each do |path|
1061
+ FileUtils.rm_rf(path)
1062
+ end
1063
+
1064
+ copy_gemspec_root_files(git_repo, gem_root, dest_root, spec)
1065
+ FS.atomic_move(tmp_assembled, assembling)
1066
+
1067
+ gem_subdir = begin
1068
+ resolve_git_gem_subdir(assembling, spec)
1069
+ rescue InstallError
1070
+ assembling
1071
+ end
1072
+ gemspec = read_gemspec_from_extracted(gem_subdir, spec)
1073
+ cache_gemspec(spec, gemspec, cache) if gemspec
1074
+
1075
+ unless Installer::ExtensionBuilder.needs_build?(spec, assembling)
1076
+ promote_assembled_gem(spec, cache, assembling, gemspec, extensions: false)
1077
+ end
1078
+ ensure
1079
+ FileUtils.rm_rf(tmp_assembled) if tmp_assembled && File.exist?(tmp_assembled)
1080
+ end
980
1081
  end
981
1082
  end
982
1083
 
@@ -993,61 +1094,76 @@ module Scint
993
1094
  source.respond_to?(:submodules) && !!source.submodules
994
1095
  end
995
1096
 
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}"
1097
+ def copy_gemspec_root_files(repo_root, gem_root, dest_root, spec)
1098
+ repo_root = File.expand_path(repo_root.to_s)
1099
+ gem_root = File.expand_path(gem_root.to_s)
1100
+ return if repo_root == gem_root
1101
+
1102
+ gemspec_path = git_gemspec_path_for_root(gem_root, spec)
1103
+ return unless gemspec_path && File.exist?(gemspec_path)
1104
+
1105
+ content = File.read(gemspec_path) rescue nil
1106
+ return unless content
1107
+
1108
+ root_files = git_root_files_from_gemspec(content)
1109
+ root_files.each do |file|
1110
+ source = File.join(repo_root, file)
1111
+ next unless File.file?(source)
1112
+
1113
+ dest = File.join(dest_root, file)
1114
+ next if File.exist?(dest)
1115
+
1116
+ FS.clonefile(source, dest)
1009
1117
  end
1010
1118
  end
1011
1119
 
1012
- def checkout_git_tree_with_submodules(bare_repo, destination, resolved_revision, spec, uri)
1013
- worktree = "#{destination}.worktree"
1014
- FileUtils.rm_rf(worktree)
1120
+ def git_gemspec_path_for_root(gem_root, spec)
1121
+ if spec && spec.respond_to?(:name)
1122
+ candidate = File.join(gem_root, "#{spec.name}.gemspec")
1123
+ return candidate if File.exist?(candidate)
1124
+ end
1125
+
1126
+ Dir.glob(File.join(gem_root, "*.gemspec")).first
1127
+ end
1128
+
1129
+ def git_root_files_from_gemspec(content)
1130
+ files = ["RAILS_VERSION", "VERSION"]
1131
+ files.select { |file| content.include?(file) }
1132
+ end
1133
+
1134
+ def git_relative_root(repo_root, gem_root)
1135
+ repo_root = File.expand_path(repo_root.to_s)
1136
+ gem_root = File.expand_path(gem_root.to_s)
1137
+ return "" if repo_root == gem_root
1138
+
1139
+ if gem_root.start_with?("#{repo_root}/")
1140
+ return gem_root.delete_prefix("#{repo_root}/")
1141
+ end
1142
+
1143
+ File.basename(gem_root)
1144
+ end
1015
1145
 
1146
+ def checkout_git_revision(git_repo, resolved_revision, spec, uri, submodules: false)
1016
1147
  _out, err, status = git_capture3(
1017
- "--git-dir", bare_repo,
1018
- "worktree",
1019
- "add",
1020
- "--detach",
1021
- "--force",
1022
- worktree,
1023
- resolved_revision,
1148
+ "-C", git_repo,
1149
+ "checkout", "-f", resolved_revision,
1024
1150
  )
1025
1151
  unless status.success?
1026
- raise InstallError, "Git worktree checkout failed for #{spec.name} (#{uri}@#{resolved_revision}): #{err.to_s.strip}"
1152
+ raise InstallError, "Git checkout failed for #{spec.name} (#{uri}@#{resolved_revision}): #{err.to_s.strip}"
1027
1153
  end
1028
1154
 
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)
1155
+ return unless submodules
1043
1156
 
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)
1157
+ _sub_out, sub_err, sub_status = git_capture3(
1158
+ "-C", git_repo,
1159
+ "-c", "protocol.file.allow=always",
1160
+ "submodule",
1161
+ "update",
1162
+ "--init",
1163
+ "--recursive",
1164
+ )
1165
+ unless sub_status.success?
1166
+ raise InstallError, "Git submodule update failed for #{spec.name} (#{uri}@#{resolved_revision}): #{sub_err.to_s.strip}"
1051
1167
  end
1052
1168
  end
1053
1169
 
@@ -1059,32 +1175,35 @@ module Scint
1059
1175
  end
1060
1176
  end
1061
1177
 
1062
- def clone_git_repo(uri, bare_repo)
1063
- FS.mkdir_p(File.dirname(bare_repo))
1064
- _out, err, status = git_capture3("clone", "--bare", uri.to_s, bare_repo)
1178
+ def clone_git_repo(uri, git_repo)
1179
+ FS.mkdir_p(File.dirname(git_repo))
1180
+ _out, err, status = git_capture3("clone", uri.to_s, git_repo)
1065
1181
  unless status.success?
1066
1182
  raise InstallError, "Git clone failed for #{uri}: #{err.to_s.strip}"
1067
1183
  end
1068
1184
  end
1069
1185
 
1070
- def fetch_git_repo(bare_repo)
1186
+ def fetch_git_repo(git_repo)
1071
1187
  _out, err, status = git_capture3(
1072
- "--git-dir", bare_repo,
1188
+ "-C", git_repo,
1073
1189
  "fetch",
1074
1190
  "--prune",
1191
+ "--force",
1075
1192
  "origin",
1076
- "+refs/heads/*:refs/heads/*",
1077
- "+refs/tags/*:refs/tags/*",
1078
1193
  )
1079
1194
  unless status.success?
1080
- raise InstallError, "Git fetch failed for #{bare_repo}: #{err.to_s.strip}"
1195
+ raise InstallError, "Git fetch failed for #{git_repo}: #{err.to_s.strip}"
1081
1196
  end
1082
1197
  end
1083
1198
 
1084
- def resolve_git_revision(bare_repo, revision)
1085
- out, err, status = git_capture3("--git-dir", bare_repo, "rev-parse", "#{revision}^{commit}")
1199
+ def resolve_git_revision(git_repo, revision)
1200
+ # Try origin/<revision> first so we pick up fetched branch tips.
1201
+ out, _err, status = git_capture3("-C", git_repo, "rev-parse", "origin/#{revision}^{commit}")
1202
+ return out.strip if status.success?
1203
+
1204
+ out, err, status = git_capture3("-C", git_repo, "rev-parse", "#{revision}^{commit}")
1086
1205
  unless status.success?
1087
- raise InstallError, "Unable to resolve git revision #{revision.inspect} in #{bare_repo}: #{err.to_s.strip}"
1206
+ raise InstallError, "Unable to resolve git revision #{revision.inspect} in #{git_repo}: #{err.to_s.strip}"
1088
1207
  end
1089
1208
  out.strip
1090
1209
  end
@@ -1093,64 +1212,59 @@ module Scint
1093
1212
  Open3.capture3("git", "-c", "core.fsmonitor=false", *args)
1094
1213
  end
1095
1214
 
1096
- def git_checkout_marker_path(dir)
1097
- "#{dir}.scint_git_revision"
1098
- end
1099
-
1100
- def parse_git_checkout_marker(path)
1101
- content = File.read(path)
1102
- revision = nil
1103
- submodules = nil
1104
-
1105
- content.each_line do |line|
1106
- key, value = line.strip.split("=", 2)
1107
- case key
1108
- when "revision"
1109
- revision = value
1110
- when "submodules"
1111
- submodules = (value == "1" || value == "true")
1112
- end
1113
- end
1114
-
1115
- if revision.nil?
1116
- # Legacy format: raw revision only.
1117
- revision = content.strip
1118
- end
1119
-
1120
- { revision: revision, submodules: submodules }
1121
- rescue StandardError
1122
- { revision: nil, submodules: nil }
1123
- end
1215
+ def compile_slots_for(worker_count)
1216
+ # Scale compile concurrency with available CPUs.
1217
+ # Most native extensions have 1-3 source files and don't benefit from
1218
+ # high make -j; running more concurrent builds is more effective.
1219
+ # Each slot gets cpu_count/slots make jobs (see adaptive_make_jobs).
1220
+ workers = [worker_count.to_i, 1].max
1221
+ override = positive_integer_env("SCINT_COMPILE_CONCURRENCY")
1222
+ return [override, workers].min if override
1124
1223
 
1125
- def format_git_checkout_marker(revision, submodules:)
1126
- "revision=#{revision}\nsubmodules=#{submodules ? 1 : 0}\n"
1224
+ cpus = Platform.cpu_count
1225
+ # Aim for 8 make-jobs per slot → slots = cpus / 8, clamped.
1226
+ slots = cpus / 8
1227
+ slots = [[slots, 2].max, workers].min
1228
+ slots
1127
1229
  end
1128
1230
 
1129
- def compile_slots_for(worker_count)
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
1231
+ def git_slots_for(worker_count)
1232
+ workers = [worker_count.to_i, 1].max
1233
+ override = positive_integer_env("SCINT_GIT_CONCURRENCY")
1234
+ slots = override || workers
1235
+ [[slots, workers].min, 1].max
1136
1236
  end
1137
1237
 
1138
- def install_task_limits(worker_count, compile_slots)
1238
+ def install_task_limits(worker_count, compile_slots, git_slots = worker_count)
1139
1239
  # Leave headroom for compile and binstub lanes so link/download
1140
1240
  # throughput cannot fully starve them.
1141
- io_cpu_limit = [worker_count - compile_slots - 1, 1].max
1241
+ workers = [worker_count.to_i, 1].max
1242
+ io_cpu_limit = [workers - compile_slots - 1, 1].max
1142
1243
  # Keep download in-flight set bounded so fail-fast exits quickly on
1143
1244
  # auth/source errors instead of queueing a large burst.
1144
1245
  download_limit = [io_cpu_limit, 8].min
1246
+ git_limit = [[git_slots.to_i, 1].max, workers].min
1145
1247
  {
1146
1248
  download: download_limit,
1147
1249
  extract: io_cpu_limit,
1148
1250
  link: io_cpu_limit,
1251
+ git_clone: git_limit,
1149
1252
  build_ext: compile_slots,
1150
1253
  binstub: 1,
1151
1254
  }
1152
1255
  end
1153
1256
 
1257
+ def positive_integer_env(key)
1258
+ raw = ENV[key]
1259
+ return nil if raw.nil? || raw.empty?
1260
+
1261
+ value = Integer(raw, exception: false)
1262
+ return nil unless value
1263
+ return nil if value <= 0
1264
+
1265
+ value
1266
+ end
1267
+
1154
1268
  def display_bundle_path(path)
1155
1269
  return path if path.start_with?("/", "./", "../")
1156
1270
 
@@ -1297,10 +1411,21 @@ module Scint
1297
1411
  source_str
1298
1412
  end
1299
1413
  else
1300
- base = entry.cached_path || cache.extracted_path(entry.spec)
1301
- if git_source?(entry.spec.source) && Dir.exist?(base)
1414
+ cached_dir = cache.cached_path(entry.spec)
1415
+ assembling = cache.assembling_path(entry.spec)
1416
+ base = if entry.cached_path
1417
+ entry.cached_path
1418
+ elsif Scint::Cache::Validity.cached_valid?(entry.spec, cache)
1419
+ cached_dir
1420
+ elsif Dir.exist?(assembling)
1421
+ assembling
1422
+ else
1423
+ nil
1424
+ end
1425
+
1426
+ if git_source?(entry.spec.source) && base && Dir.exist?(base)
1302
1427
  resolve_git_gem_subdir(base, entry.spec)
1303
- elsif path_source?(entry.spec.source) && Dir.exist?(base)
1428
+ elsif path_source?(entry.spec.source) && base && Dir.exist?(base)
1304
1429
  begin
1305
1430
  resolve_path_gem_subdir(base, entry.spec)
1306
1431
  rescue InstallError
@@ -1371,19 +1496,18 @@ module Scint
1371
1496
  from_cache: true,
1372
1497
  )
1373
1498
  Installer::Linker.link_files(prepared, bundle_path)
1374
- # If this gem has a cached native build, materialize it during link.
1375
- # This lets reinstalling into a fresh .bundle skip build_ext entirely.
1376
- Installer::ExtensionBuilder.link_cached_build(prepared, bundle_path, cache)
1377
1499
  end
1378
1500
 
1379
1501
  def build_extensions(entry, cache, bundle_path, progress = nil, compile_slots: 1)
1502
+ spec = entry.spec
1380
1503
  extracted = extracted_path_for_entry(entry, cache)
1381
- gemspec = load_gemspec(extracted, entry.spec, cache)
1504
+ gemspec = load_gemspec(extracted, spec, cache)
1505
+ promote_after_build = assembling_path?(extracted, cache)
1382
1506
 
1383
- sync_build_env_dependencies(entry.spec, bundle_path, cache)
1507
+ sync_build_env_dependencies(spec, bundle_path, cache)
1384
1508
 
1385
1509
  prepared = PreparedGem.new(
1386
- spec: entry.spec,
1510
+ spec: spec,
1387
1511
  extracted_path: extracted,
1388
1512
  gemspec: gemspec,
1389
1513
  from_cache: true,
@@ -1394,8 +1518,24 @@ module Scint
1394
1518
  bundle_path,
1395
1519
  cache,
1396
1520
  compile_slots: compile_slots,
1397
- output_tail: ->(lines) { progress&.on_build_tail(entry.spec.name, lines) },
1521
+ output_tail: ->(lines) { progress&.on_build_tail(spec.name, lines) },
1398
1522
  )
1523
+
1524
+ ruby_dir = Platform.ruby_install_dir(bundle_path)
1525
+ bundle_gem_dir = File.join(ruby_dir, "gems", SpecUtils.full_name(spec))
1526
+ if Dir.exist?(bundle_gem_dir)
1527
+ Installer::ExtensionBuilder.sync_extensions_into_gem(extracted, bundle_gem_dir)
1528
+ File.write(File.join(bundle_gem_dir, Installer::ExtensionBuilder::BUILD_MARKER), "")
1529
+ end
1530
+
1531
+ return unless promote_after_build
1532
+
1533
+ promote_assembled_gem(spec, cache, extracted, gemspec, extensions: true)
1534
+ rescue StandardError
1535
+ if promote_after_build && extracted && Dir.exist?(extracted)
1536
+ FileUtils.rm_rf(extracted)
1537
+ end
1538
+ raise
1399
1539
  end
1400
1540
 
1401
1541
  def sync_build_env_dependencies(spec, bundle_path, cache)
@@ -1460,6 +1600,12 @@ module Scint
1460
1600
  return cached
1461
1601
  end
1462
1602
 
1603
+ direct = read_gemspec_from_extracted(extracted_path, spec)
1604
+ if direct
1605
+ @gemspec_cache_lock.synchronize { @gemspec_cache[cache_key] = direct }
1606
+ return direct
1607
+ end
1608
+
1463
1609
  inbound = cache.inbound_path(spec)
1464
1610
  return nil unless File.exist?(inbound)
1465
1611
 
@@ -1473,49 +1619,90 @@ module Scint
1473
1619
  end
1474
1620
  end
1475
1621
 
1622
+ def read_gemspec_from_extracted(extracted_dir, spec)
1623
+ return nil unless extracted_dir && Dir.exist?(extracted_dir)
1624
+
1625
+ pattern = File.join(extracted_dir, "*.gemspec")
1626
+ candidates = Dir.glob(pattern)
1627
+ return nil if candidates.empty?
1628
+
1629
+ load_gemspec_file(candidates.first, spec)
1630
+ end
1631
+
1632
+ # Load a .gemspec file, temporarily injecting VERSION env var for gems
1633
+ # like kgio/unicorn that use `ENV["VERSION"] or abort` in their gemspec.
1634
+ def load_gemspec_file(path, spec = nil)
1635
+ version = spec.respond_to?(:version) ? spec.version.to_s : nil
1636
+ old_version = ENV["VERSION"]
1637
+ begin
1638
+ ENV["VERSION"] = version if version && !ENV["VERSION"]
1639
+ SpecUtils.load_gemspec(path, isolate: true)
1640
+ rescue SystemExit, StandardError
1641
+ nil
1642
+ ensure
1643
+ ENV["VERSION"] = old_version
1644
+ end
1645
+ end
1646
+
1476
1647
  def bulk_prelink_gem_files(entries, cache, bundle_path)
1477
- # Keep small installs simple; batching is for large warm-path runs.
1478
1648
  return if entries.length < 32
1479
1649
 
1480
1650
  ruby_dir = File.join(bundle_path, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
1481
1651
  gems_dir = File.join(ruby_dir, "gems")
1652
+ cache_abi_dir = cache.cached_abi_dir
1482
1653
 
1483
- sources = entries.filter_map do |entry|
1654
+ gem_names = []
1655
+ entries.each do |entry|
1484
1656
  next unless entry.action == :link || entry.action == :build_ext
1485
1657
 
1486
- extracted = cache.extracted_path(entry.spec)
1658
+ source_dir = entry.cached_path
1659
+ next unless source_dir
1660
+
1487
1661
  full_name = cache.full_name(entry.spec)
1488
- next unless File.basename(extracted) == full_name
1489
- next unless Dir.exist?(extracted)
1662
+ next unless File.basename(source_dir) == full_name
1663
+ next unless Dir.exist?(source_dir)
1490
1664
  next if Dir.exist?(File.join(gems_dir, full_name))
1491
1665
 
1492
- extracted
1666
+ gem_names << full_name
1667
+ end
1668
+
1669
+ return if gem_names.empty?
1670
+
1671
+ if ENV["SCINT_TIMING"]
1672
+ @output.puts " [timing] prelink: #{gem_names.size} gems via linker"
1493
1673
  end
1494
- return if sources.empty?
1495
1674
 
1496
- FS.clone_many_trees(sources, gems_dir)
1675
+ FS.bulk_link_gems(cache_abi_dir, gems_dir, gem_names)
1497
1676
  rescue StandardError => e
1498
- $stderr.puts("bulk prelink warning: #{e.message}") if ENV["SCINT_DEBUG"]
1677
+ @output.puts("bulk prelink warning: #{e.message}") if ENV["SCINT_DEBUG"]
1499
1678
  end
1500
1679
 
1501
1680
  def load_cached_gemspec(spec, cache, extracted_path)
1502
- path = cache.spec_cache_path(spec)
1503
- return nil unless File.exist?(path)
1681
+ paths = [cache.cached_spec_path(spec)]
1504
1682
 
1505
- data = File.binread(path)
1506
- gemspec = if data.start_with?("---")
1507
- Gem::Specification.from_yaml(data)
1508
- else
1509
- begin
1510
- Marshal.load(data)
1511
- rescue StandardError
1683
+ paths.each do |path|
1684
+ next unless File.exist?(path)
1685
+
1686
+ data = File.binread(path)
1687
+ gemspec = if data.start_with?("# -*- encoding")
1688
+ # Ruby format (to_ruby output) — most reliable, preserves require_paths
1689
+ Gem::Specification.load(path)
1690
+ elsif data.start_with?("---")
1691
+ data.force_encoding("UTF-8") if data.encoding != Encoding::UTF_8
1512
1692
  Gem::Specification.from_yaml(data)
1693
+ else
1694
+ begin
1695
+ Marshal.load(data)
1696
+ rescue StandardError
1697
+ data.force_encoding("UTF-8") if data.encoding != Encoding::UTF_8
1698
+ Gem::Specification.from_yaml(data)
1699
+ end
1513
1700
  end
1701
+ return gemspec if cached_gemspec_valid?(gemspec, extracted_path)
1514
1702
  end
1515
- return gemspec if cached_gemspec_valid?(gemspec, extracted_path)
1516
1703
 
1517
1704
  nil
1518
- rescue StandardError
1705
+ rescue SystemExit, StandardError
1519
1706
  nil
1520
1707
  end
1521
1708
 
@@ -1549,12 +1736,108 @@ module Scint
1549
1736
  end
1550
1737
 
1551
1738
  def cache_gemspec(spec, gemspec, cache)
1552
- path = cache.spec_cache_path(spec)
1553
- FS.atomic_write(path, gemspec.to_yaml)
1739
+ path = cache.cached_spec_path(spec)
1740
+ content = if gemspec.respond_to?(:to_ruby)
1741
+ gemspec.to_ruby
1742
+ else
1743
+ gemspec.to_yaml
1744
+ end
1745
+ FS.atomic_write(path, content)
1554
1746
  rescue StandardError
1555
1747
  # Non-fatal: we'll read metadata from .gem next time.
1556
1748
  end
1557
1749
 
1750
+ def cache_promoter(cache)
1751
+ @cache_promoter ||= Installer::Promoter.new(root: cache.root)
1752
+ end
1753
+
1754
+ def assembling_path?(path, cache)
1755
+ return false if path.nil? || path.empty?
1756
+
1757
+ root = File.expand_path(cache.assembling_dir)
1758
+ candidate = File.expand_path(path)
1759
+ candidate == root || candidate.start_with?("#{root}/")
1760
+ end
1761
+
1762
+ def promote_assembled_gem(spec, cache, assembling_path, gemspec, extensions:)
1763
+ return unless assembling_path && Dir.exist?(assembling_path)
1764
+
1765
+ cached_dir = cache.cached_path(spec)
1766
+ promoter = cache_promoter(cache)
1767
+ lock_key = "#{Platform.abi_key}-#{cache.full_name(spec)}"
1768
+
1769
+ promoter.validate_within_root!(cache.root, assembling_path, label: "assembling")
1770
+ promoter.validate_within_root!(cache.root, cached_dir, label: "cached")
1771
+
1772
+ begin
1773
+ result = nil
1774
+ promoter.with_staging_dir(prefix: "cached") do |staging|
1775
+ FS.clone_tree(assembling_path, staging)
1776
+ manifest = build_cached_manifest(spec, cache, staging, extensions: extensions)
1777
+ Scint::Cache::Manifest.write_dotfiles(staging, manifest)
1778
+ spec_payload = gemspec ? gemspec.to_ruby : nil
1779
+ result = promoter.promote_tree(
1780
+ staging_path: staging,
1781
+ target_path: cached_dir,
1782
+ lock_key: lock_key,
1783
+ )
1784
+ if result == :promoted
1785
+ write_cached_metadata(spec, cache, spec_payload, manifest)
1786
+ end
1787
+ FileUtils.rm_rf(assembling_path) if Dir.exist?(assembling_path)
1788
+ end
1789
+ result
1790
+ rescue StandardError
1791
+ FileUtils.rm_rf(cached_dir) if Dir.exist?(cached_dir)
1792
+ raise
1793
+ end
1794
+ end
1795
+
1796
+ def write_cached_metadata(spec, cache, spec_payload, manifest)
1797
+ spec_path = cache.cached_spec_path(spec)
1798
+ manifest_path = cache.cached_manifest_path(spec)
1799
+ FS.mkdir_p(File.dirname(spec_path))
1800
+
1801
+ FS.atomic_write(spec_path, spec_payload) if spec_payload
1802
+ Scint::Cache::Manifest.write(manifest_path, manifest)
1803
+ end
1804
+
1805
+ def build_cached_manifest(spec, cache, gem_dir, extensions:)
1806
+ Scint::Cache::Manifest.build(
1807
+ spec: spec,
1808
+ gem_dir: gem_dir,
1809
+ abi_key: Platform.abi_key,
1810
+ source: manifest_source_for(spec),
1811
+ extensions: extensions,
1812
+ )
1813
+ end
1814
+
1815
+ def manifest_source_for(spec)
1816
+ source = spec.source
1817
+ if source.is_a?(Source::Git)
1818
+ {
1819
+ "type" => "git",
1820
+ "uri" => source.uri.to_s,
1821
+ "revision" => source.revision || source.ref || source.branch || source.tag,
1822
+ }.compact
1823
+ elsif source.is_a?(Source::Path)
1824
+ {
1825
+ "type" => "path",
1826
+ "path" => File.expand_path(source.path.to_s),
1827
+ "uri" => source.path.to_s,
1828
+ }
1829
+ else
1830
+ source_str = source.to_s
1831
+ if source_str.start_with?("http://", "https://")
1832
+ { "type" => "rubygems", "uri" => source_str }
1833
+ elsif path_source?(source)
1834
+ { "type" => "path", "path" => File.expand_path(source_str), "uri" => source_str }
1835
+ else
1836
+ { "type" => "rubygems", "uri" => source_str }
1837
+ end
1838
+ end
1839
+ end
1840
+
1558
1841
  # --- Lockfile + runtime config ---
1559
1842
 
1560
1843
  def write_lockfile(resolved, gemfile, lockfile = nil)
@@ -1963,10 +2246,10 @@ module Scint
1963
2246
  def read_require_paths(spec_file)
1964
2247
  return ["lib"] unless File.exist?(spec_file)
1965
2248
 
1966
- gemspec = Gem::Specification.load(spec_file)
2249
+ gemspec = SpecUtils.load_gemspec(spec_file)
1967
2250
  paths = Array(gemspec&.require_paths).reject(&:empty?)
1968
2251
  paths.empty? ? ["lib"] : paths
1969
- rescue StandardError
2252
+ rescue SystemExit, StandardError
1970
2253
  ["lib"]
1971
2254
  end
1972
2255
 
@@ -2020,8 +2303,12 @@ module Scint
2020
2303
 
2021
2304
  # Global cache artifacts.
2022
2305
  FileUtils.rm_f(cache.inbound_path(spec))
2023
- FileUtils.rm_rf(cache.extracted_path(spec))
2306
+ FileUtils.rm_rf(cache.assembling_path(spec))
2307
+ FileUtils.rm_rf(cache.cached_path(spec))
2308
+ FileUtils.rm_f(cache.cached_spec_path(spec))
2309
+ FileUtils.rm_f(cache.cached_manifest_path(spec))
2024
2310
  FileUtils.rm_f(cache.spec_cache_path(spec))
2311
+ FileUtils.rm_rf(cache.extracted_path(spec))
2025
2312
  FileUtils.rm_rf(cache.ext_path(spec))
2026
2313
 
2027
2314
  # Local bundle artifacts.
@@ -2056,17 +2343,17 @@ module Scint
2056
2343
  return if (headers.nil? || headers.empty?) && body.empty?
2057
2344
 
2058
2345
  if headers && !headers.empty?
2059
- $stderr.puts " headers:"
2346
+ @output.puts " headers:"
2060
2347
  headers.sort.each do |key, value|
2061
- $stderr.puts " #{key}: #{value}"
2348
+ @output.puts " #{key}: #{value}"
2062
2349
  end
2063
2350
  end
2064
2351
 
2065
2352
  return if body.empty?
2066
2353
 
2067
- $stderr.puts " body:"
2354
+ @output.puts " body:"
2068
2355
  body.each_line do |line|
2069
- $stderr.puts " #{line.rstrip}"
2356
+ @output.puts " #{line.rstrip}"
2070
2357
  end
2071
2358
  end
2072
2359
 
@@ -2075,7 +2362,7 @@ module Scint
2075
2362
  return unless File.file?(path)
2076
2363
  return if gitignore_has_bundle_entry?(path)
2077
2364
 
2078
- $stderr.puts "#{YELLOW}Warning: .gitignore exists but does not ignore .bundle (add `.bundle/`).#{RESET}"
2365
+ @output.puts "#{YELLOW}Warning: .gitignore exists but does not ignore .bundle (add `.bundle/`).#{RESET}"
2079
2366
  end
2080
2367
 
2081
2368
  def gitignore_has_bundle_entry?(path)
@@ -2111,6 +2398,12 @@ module Scint
2111
2398
  when "--path"
2112
2399
  @path = @argv[i + 1]
2113
2400
  i += 2
2401
+ when "--without"
2402
+ @without_groups = @argv[i + 1]&.split(/[\s:,]+/)&.map(&:to_sym) || []
2403
+ i += 2
2404
+ when "--with"
2405
+ @with_groups = @argv[i + 1]&.split(/[\s:,]+/)&.map(&:to_sym) || []
2406
+ i += 2
2114
2407
  when "--verbose"
2115
2408
  @verbose = true
2116
2409
  i += 1
@@ -2121,6 +2414,32 @@ module Scint
2121
2414
  i += 1
2122
2415
  end
2123
2416
  end
2417
+
2418
+ # Also read BUNDLE_WITHOUT / BUNDLE_WITH env vars (Bundler compat)
2419
+ if !@without_groups && ENV["BUNDLE_WITHOUT"]
2420
+ @without_groups = ENV["BUNDLE_WITHOUT"].split(/[\s:,]+/).map(&:to_sym)
2421
+ end
2422
+ if !@with_groups && ENV["BUNDLE_WITH"]
2423
+ @with_groups = ENV["BUNDLE_WITH"].split(/[\s:,]+/).map(&:to_sym)
2424
+ end
2425
+
2426
+ # Read from .bundle/config if present
2427
+ load_bundle_config_groups if !@without_groups && !@with_groups
2428
+ end
2429
+
2430
+ def load_bundle_config_groups
2431
+ config_path = File.join(".bundle", "config")
2432
+ return unless File.exist?(config_path)
2433
+
2434
+ config = YAML.safe_load(File.read(config_path)) rescue nil
2435
+ return unless config.is_a?(Hash)
2436
+
2437
+ if config["BUNDLE_WITHOUT"] && !@without_groups
2438
+ @without_groups = config["BUNDLE_WITHOUT"].to_s.split(/[\s:]+/).map(&:to_sym)
2439
+ end
2440
+ if config["BUNDLE_WITH"] && !@with_groups
2441
+ @with_groups = config["BUNDLE_WITH"].to_s.split(/[\s:]+/).map(&:to_sym)
2442
+ end
2124
2443
  end
2125
2444
  end
2126
2445
  end