scint 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,29 +43,43 @@ 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)
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
50
54
  @download_pool = nil
51
55
  @download_pool_lock = Thread::Mutex.new
52
56
  @gemspec_cache = {}
53
57
  @gemspec_cache_lock = Thread::Mutex.new
54
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
55
68
  end
56
69
 
57
70
  def run
58
71
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
72
+ _t = start_time
59
73
 
60
74
  cache = Scint::Cache::Layout.new
75
+ cache_telemetry = Scint::Cache::Telemetry.new
61
76
  bundle_path = @path || ENV["BUNDLER_PATH"] || ".bundle"
62
77
  bundle_display = display_bundle_path(bundle_path)
63
78
  bundle_path = File.expand_path(bundle_path)
64
- worker_count = @jobs || [Platform.cpu_count * 2, 50].min
79
+ worker_count = [(@jobs || [Platform.cpu_count * 2, 50].min).to_i, 1].max
65
80
  compile_slots = compile_slots_for(worker_count)
66
- 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)
67
83
  $stdout.puts "#{GREEN}💎#{RESET} Scintellating Gemfile into #{BOLD}#{bundle_display}#{RESET} #{DIM}(scint #{VERSION}, ruby #{RUBY_VERSION})#{RESET}"
68
84
  $stdout.puts
69
85
 
@@ -75,6 +91,7 @@ module Scint
75
91
  scheduler.start
76
92
 
77
93
  begin
94
+ _t = _tmark("startup", _t)
78
95
  # 2. Parse Gemfile
79
96
  gemfile = Scint::Gemfile::Parser.parse("Gemfile")
80
97
 
@@ -86,6 +103,7 @@ module Scint
86
103
  dep_count = gemfile.dependencies.size
87
104
  scheduler.scale_workers(dep_count)
88
105
 
106
+ _t = _tmark("parse_gemfile", _t)
89
107
  # 3. Enqueue index fetches for all sources immediately
90
108
  gemfile.sources.each do |source|
91
109
  scheduler.enqueue(:fetch_index, source[:uri] || source.to_s,
@@ -106,20 +124,26 @@ module Scint
106
124
  -> { clone_git_source(source, cache) })
107
125
  end
108
126
 
127
+ _t = _tmark("enqueue_fetches", _t)
109
128
  # 6. Wait for index fetches, then resolve
110
129
  scheduler.wait_for(:fetch_index)
130
+ _t = _tmark("wait_index", _t)
111
131
  scheduler.wait_for(:git_clone)
132
+ _t = _tmark("wait_git", _t)
112
133
 
113
134
  resolved = resolve(gemfile, lockfile, cache)
114
135
  resolved = dedupe_resolved_specs(adjust_meta_gems(resolved))
136
+ resolved = filter_excluded_gems(resolved, gemfile)
115
137
  force_purge_artifacts(resolved, bundle_path, cache) if @force
116
138
 
139
+ _t = _tmark("resolve", _t)
117
140
  # 7. Plan: diff resolved vs installed
118
- plan = Installer::Planner.plan(resolved, bundle_path, cache)
141
+ plan = Installer::Planner.plan(resolved, bundle_path, cache, telemetry: cache_telemetry)
119
142
  total_gems = resolved.size
120
143
  updated_gems = plan.count { |e| e.action != :skip }
121
144
  cached_gems = total_gems - updated_gems
122
145
  to_install = plan.reject { |e| e.action == :skip }
146
+ _t = _tmark("plan", _t)
123
147
 
124
148
  # Scale up for download/install phase based on actual work count
125
149
  scheduler.scale_workers(to_install.size)
@@ -127,6 +151,7 @@ module Scint
127
151
  # Warm-cache accelerator: pre-materialize cache-backed gem trees in
128
152
  # batches so install workers avoid one cp process per gem.
129
153
  bulk_prelink_gem_files(to_install, cache, bundle_path)
154
+ _t = _tmark("prelink", _t)
130
155
 
131
156
  if to_install.empty?
132
157
  # Keep lock artifacts aligned even when everything is already installed.
@@ -192,9 +217,13 @@ module Scint
192
217
  end
193
218
  ensure
194
219
  begin
195
- scheduler.shutdown
220
+ cache_telemetry.warn_if_needed(cache_root: cache.root)
196
221
  ensure
197
- close_download_pool
222
+ begin
223
+ scheduler.shutdown
224
+ ensure
225
+ close_download_pool
226
+ end
198
227
  end
199
228
  end
200
229
  end
@@ -233,6 +262,91 @@ module Scint
233
262
  seen.values
234
263
  end
235
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
+
236
350
  # Install scint into the bundle by copying our own lib tree.
237
351
  # No download needed — we know exactly where we are.
238
352
  def install_builtin_gem(entry, bundle_path)
@@ -429,9 +543,9 @@ module Scint
429
543
  candidates.each do |gs|
430
544
  next unless File.exist?(gs)
431
545
  begin
432
- spec = Gem::Specification.load(gs)
546
+ spec = SpecUtils.load_gemspec(gs, isolate: true)
433
547
  return spec if spec
434
- rescue StandardError
548
+ rescue SystemExit, StandardError
435
549
  nil
436
550
  end
437
551
  end
@@ -697,10 +811,8 @@ module Scint
697
811
  absolute_gemspec = File.join(worktree, gemspec_path)
698
812
  return nil unless File.exist?(absolute_gemspec)
699
813
 
700
- Dir.chdir(File.dirname(absolute_gemspec)) do
701
- Gem::Specification.load(absolute_gemspec)
702
- end
703
- rescue StandardError
814
+ SpecUtils.load_gemspec(absolute_gemspec, isolate: true)
815
+ rescue SystemExit, StandardError
704
816
  nil
705
817
  end
706
818
 
@@ -786,7 +898,7 @@ module Scint
786
898
  spec = entry.spec
787
899
  source = spec.source
788
900
  if git_source?(source)
789
- prepare_git_checkout(spec, cache, fetch: false)
901
+ ensure_git_repo_for_spec(spec, cache, fetch: true)
790
902
  return
791
903
  end
792
904
  source_uri = source.to_s
@@ -827,21 +939,34 @@ module Scint
827
939
  # Git gems are extracted from the cached checkout; path gems are
828
940
  # linked directly from local source.
829
941
  if git_source?(spec.source)
830
- materialize_git_spec(entry, cache)
942
+ assemble_git_spec(entry, cache, fetch: false)
831
943
  return
832
944
  end
833
945
  return if source_uri.start_with?("/") || !source_uri.start_with?("http")
834
946
 
835
- extracted = cache.extracted_path(spec)
836
- return if Dir.exist?(extracted)
947
+ return if Cache::Validity.cached_valid?(spec, cache)
837
948
 
838
949
  dest_path = cache.inbound_path(spec)
839
950
  raise InstallError, "Missing cached gem file for #{spec.name}: #{dest_path}" unless File.exist?(dest_path)
840
951
 
841
- FS.mkdir_p(extracted)
842
- pkg = GemPkg::Package.new
843
- result = pkg.extract(dest_path, extracted)
844
- 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
845
970
  end
846
971
 
847
972
  def git_source?(source)
@@ -870,16 +995,12 @@ module Scint
870
995
  def prepare_git_source(entry, cache)
871
996
  # Legacy helper used by tests/callers that expect git download+extract
872
997
  # 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)
998
+ assemble_git_spec(entry, cache, fetch: true)
876
999
  end
877
1000
 
878
- def prepare_git_checkout(spec, cache, fetch: false)
1001
+ def ensure_git_repo_for_spec(spec, cache, fetch:)
879
1002
  source = spec.source
880
- uri, revision = git_source_ref(source)
881
- submodules = git_source_submodules?(source)
882
-
1003
+ uri, _revision = git_source_ref(source)
883
1004
  bare_repo = cache.git_path(uri)
884
1005
 
885
1006
  # Serialize all git operations per bare repo — git uses index.lock
@@ -891,92 +1012,100 @@ module Scint
891
1012
  clone_git_repo(uri, bare_repo)
892
1013
  fetch_git_repo(bare_repo)
893
1014
  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
1015
  end
1016
+
1017
+ bare_repo
907
1018
  end
908
1019
 
909
- def materialize_git_spec(entry, cache, checkout_dir: nil, resolved_revision: nil)
1020
+ def assemble_git_spec(entry, cache, fetch: true)
910
1021
  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
1022
+ return if Cache::Validity.cached_valid?(spec, cache)
921
1023
 
922
- tmp = "#{extracted}.#{Process.pid}.#{Thread.current.object_id}.tmp"
923
- begin
924
- FileUtils.rm_rf(tmp)
925
- FS.clone_tree(gem_root, tmp)
1024
+ source = spec.source
1025
+ uri, revision = git_source_ref(source)
1026
+ submodules = git_source_submodules?(source)
926
1027
 
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
1028
+ bare_repo = cache.git_path(uri)
934
1029
 
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
1030
+ # Serialize all git operations per bare repo — git uses index.lock
1031
+ # and can't handle concurrent checkouts from the same repo.
1032
+ git_mutex_for(bare_repo).synchronize do
1033
+ tmp_checkout = nil
1034
+ tmp_assembled = nil
940
1035
 
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]
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
947
1043
 
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
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
953
1076
 
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
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
974
1082
 
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)
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
1098
+ end
1099
+ gemspec = read_gemspec_from_extracted(gem_subdir, spec)
1100
+ cache_gemspec(spec, gemspec, cache) if gemspec
1101
+
1102
+ unless Installer::ExtensionBuilder.needs_build?(spec, assembling)
1103
+ promote_assembled_gem(spec, cache, assembling, gemspec, extensions: false)
1104
+ end
1105
+ ensure
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)
1108
+ end
980
1109
  end
981
1110
  end
982
1111
 
@@ -993,6 +1122,55 @@ module Scint
993
1122
  source.respond_to?(:submodules) && !!source.submodules
994
1123
  end
995
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
+
996
1174
  def checkout_git_tree(bare_repo, destination, resolved_revision, spec, uri)
997
1175
  FileUtils.mkdir_p(destination)
998
1176
  _out, err, status = git_capture3(
@@ -1093,64 +1271,59 @@ module Scint
1093
1271
  Open3.capture3("git", "-c", "core.fsmonitor=false", *args)
1094
1272
  end
1095
1273
 
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
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
1119
1282
 
1120
- { revision: revision, submodules: submodules }
1121
- rescue StandardError
1122
- { revision: nil, submodules: nil }
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
1123
1288
  end
1124
1289
 
1125
- def format_git_checkout_marker(revision, submodules:)
1126
- "revision=#{revision}\nsubmodules=#{submodules ? 1 : 0}\n"
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
1127
1295
  end
1128
1296
 
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
1136
- end
1137
-
1138
- def install_task_limits(worker_count, compile_slots)
1297
+ def install_task_limits(worker_count, compile_slots, git_slots = worker_count)
1139
1298
  # Leave headroom for compile and binstub lanes so link/download
1140
1299
  # throughput cannot fully starve them.
1141
- 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
1142
1302
  # Keep download in-flight set bounded so fail-fast exits quickly on
1143
1303
  # auth/source errors instead of queueing a large burst.
1144
1304
  download_limit = [io_cpu_limit, 8].min
1305
+ git_limit = [[git_slots.to_i, 1].max, workers].min
1145
1306
  {
1146
1307
  download: download_limit,
1147
1308
  extract: io_cpu_limit,
1148
1309
  link: io_cpu_limit,
1310
+ git_clone: git_limit,
1149
1311
  build_ext: compile_slots,
1150
1312
  binstub: 1,
1151
1313
  }
1152
1314
  end
1153
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
+
1154
1327
  def display_bundle_path(path)
1155
1328
  return path if path.start_with?("/", "./", "../")
1156
1329
 
@@ -1297,10 +1470,21 @@ module Scint
1297
1470
  source_str
1298
1471
  end
1299
1472
  else
1300
- base = entry.cached_path || cache.extracted_path(entry.spec)
1301
- 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)
1302
1486
  resolve_git_gem_subdir(base, entry.spec)
1303
- elsif path_source?(entry.spec.source) && Dir.exist?(base)
1487
+ elsif path_source?(entry.spec.source) && base && Dir.exist?(base)
1304
1488
  begin
1305
1489
  resolve_path_gem_subdir(base, entry.spec)
1306
1490
  rescue InstallError
@@ -1371,19 +1555,18 @@ module Scint
1371
1555
  from_cache: true,
1372
1556
  )
1373
1557
  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
1558
  end
1378
1559
 
1379
1560
  def build_extensions(entry, cache, bundle_path, progress = nil, compile_slots: 1)
1561
+ spec = entry.spec
1380
1562
  extracted = extracted_path_for_entry(entry, cache)
1381
- gemspec = load_gemspec(extracted, entry.spec, cache)
1563
+ gemspec = load_gemspec(extracted, spec, cache)
1564
+ promote_after_build = assembling_path?(extracted, cache)
1382
1565
 
1383
- sync_build_env_dependencies(entry.spec, bundle_path, cache)
1566
+ sync_build_env_dependencies(spec, bundle_path, cache)
1384
1567
 
1385
1568
  prepared = PreparedGem.new(
1386
- spec: entry.spec,
1569
+ spec: spec,
1387
1570
  extracted_path: extracted,
1388
1571
  gemspec: gemspec,
1389
1572
  from_cache: true,
@@ -1394,8 +1577,24 @@ module Scint
1394
1577
  bundle_path,
1395
1578
  cache,
1396
1579
  compile_slots: compile_slots,
1397
- output_tail: ->(lines) { progress&.on_build_tail(entry.spec.name, lines) },
1580
+ output_tail: ->(lines) { progress&.on_build_tail(spec.name, lines) },
1398
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
1399
1598
  end
1400
1599
 
1401
1600
  def sync_build_env_dependencies(spec, bundle_path, cache)
@@ -1460,6 +1659,12 @@ module Scint
1460
1659
  return cached
1461
1660
  end
1462
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
1667
+
1463
1668
  inbound = cache.inbound_path(spec)
1464
1669
  return nil unless File.exist?(inbound)
1465
1670
 
@@ -1473,49 +1678,90 @@ module Scint
1473
1678
  end
1474
1679
  end
1475
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
+
1476
1706
  def bulk_prelink_gem_files(entries, cache, bundle_path)
1477
- # Keep small installs simple; batching is for large warm-path runs.
1478
1707
  return if entries.length < 32
1479
1708
 
1480
1709
  ruby_dir = File.join(bundle_path, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
1481
1710
  gems_dir = File.join(ruby_dir, "gems")
1711
+ cache_abi_dir = cache.cached_abi_dir
1482
1712
 
1483
- sources = entries.filter_map do |entry|
1713
+ gem_names = []
1714
+ entries.each do |entry|
1484
1715
  next unless entry.action == :link || entry.action == :build_ext
1485
1716
 
1486
- extracted = cache.extracted_path(entry.spec)
1717
+ source_dir = entry.cached_path
1718
+ next unless source_dir
1719
+
1487
1720
  full_name = cache.full_name(entry.spec)
1488
- next unless File.basename(extracted) == full_name
1489
- next unless Dir.exist?(extracted)
1721
+ next unless File.basename(source_dir) == full_name
1722
+ next unless Dir.exist?(source_dir)
1490
1723
  next if Dir.exist?(File.join(gems_dir, full_name))
1491
1724
 
1492
- extracted
1725
+ gem_names << full_name
1493
1726
  end
1494
- return if sources.empty?
1495
1727
 
1496
- FS.clone_many_trees(sources, gems_dir)
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)
1497
1735
  rescue StandardError => e
1498
1736
  $stderr.puts("bulk prelink warning: #{e.message}") if ENV["SCINT_DEBUG"]
1499
1737
  end
1500
1738
 
1501
1739
  def load_cached_gemspec(spec, cache, extracted_path)
1502
- path = cache.spec_cache_path(spec)
1503
- return nil unless File.exist?(path)
1740
+ paths = [cache.cached_spec_path(spec)]
1504
1741
 
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
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
1512
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
1513
1759
  end
1760
+ return gemspec if cached_gemspec_valid?(gemspec, extracted_path)
1514
1761
  end
1515
- return gemspec if cached_gemspec_valid?(gemspec, extracted_path)
1516
1762
 
1517
1763
  nil
1518
- rescue StandardError
1764
+ rescue SystemExit, StandardError
1519
1765
  nil
1520
1766
  end
1521
1767
 
@@ -1549,12 +1795,108 @@ module Scint
1549
1795
  end
1550
1796
 
1551
1797
  def cache_gemspec(spec, gemspec, cache)
1552
- path = cache.spec_cache_path(spec)
1553
- 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)
1554
1805
  rescue StandardError
1555
1806
  # Non-fatal: we'll read metadata from .gem next time.
1556
1807
  end
1557
1808
 
1809
+ def cache_promoter(cache)
1810
+ @cache_promoter ||= Installer::Promoter.new(root: cache.root)
1811
+ end
1812
+
1813
+ def assembling_path?(path, cache)
1814
+ return false if path.nil? || path.empty?
1815
+
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,
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)
1847
+ end
1848
+ result
1849
+ rescue StandardError
1850
+ FileUtils.rm_rf(cached_dir) if Dir.exist?(cached_dir)
1851
+ raise
1852
+ end
1853
+ end
1854
+
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))
1859
+
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
1873
+
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
1897
+ end
1898
+ end
1899
+
1558
1900
  # --- Lockfile + runtime config ---
1559
1901
 
1560
1902
  def write_lockfile(resolved, gemfile, lockfile = nil)
@@ -1963,10 +2305,10 @@ module Scint
1963
2305
  def read_require_paths(spec_file)
1964
2306
  return ["lib"] unless File.exist?(spec_file)
1965
2307
 
1966
- gemspec = Gem::Specification.load(spec_file)
2308
+ gemspec = SpecUtils.load_gemspec(spec_file)
1967
2309
  paths = Array(gemspec&.require_paths).reject(&:empty?)
1968
2310
  paths.empty? ? ["lib"] : paths
1969
- rescue StandardError
2311
+ rescue SystemExit, StandardError
1970
2312
  ["lib"]
1971
2313
  end
1972
2314
 
@@ -2020,8 +2362,12 @@ module Scint
2020
2362
 
2021
2363
  # Global cache artifacts.
2022
2364
  FileUtils.rm_f(cache.inbound_path(spec))
2023
- 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))
2024
2369
  FileUtils.rm_f(cache.spec_cache_path(spec))
2370
+ FileUtils.rm_rf(cache.extracted_path(spec))
2025
2371
  FileUtils.rm_rf(cache.ext_path(spec))
2026
2372
 
2027
2373
  # Local bundle artifacts.
@@ -2111,6 +2457,12 @@ module Scint
2111
2457
  when "--path"
2112
2458
  @path = @argv[i + 1]
2113
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
2114
2466
  when "--verbose"
2115
2467
  @verbose = true
2116
2468
  i += 1
@@ -2121,6 +2473,32 @@ module Scint
2121
2473
  i += 1
2122
2474
  end
2123
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
2124
2502
  end
2125
2503
  end
2126
2504
  end