scint 0.1.0 → 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2dfc2e1d04a71bb75faa5638b47b53c72449a3ebc857da9c83286ff4d5811ba2
4
- data.tar.gz: c0e0375479b2dc2522289685583b13a5dd2637b94e5999557f1a32e31943ef7f
3
+ metadata.gz: d1c0c2f09127da28473aad491848c5b3e7b976fa41405a94ce542859fa1aabf8
4
+ data.tar.gz: 2e7b1801b9a3004bc0d9709de9502bfff5a8b475178aa102ebb730a6cb1b6344
5
5
  SHA512:
6
- metadata.gz: 54ede6af6c782642dee0fe66607830ddd78a7bda1e669e6d429188da2f0d0e97733cd014ddd80ce43c97cc9987296f699c9e274adfcef88ac7aa133369b42075
7
- data.tar.gz: fb5bd293409889ddf8ce7ff16b61f51752528dc204340412f3aefca9f334221740be236fe3ba281c4325778645da03dd15a563aae101fbcaa5cbf07c91cca7a2
6
+ metadata.gz: 3d95e5006966b2025c5e21951bc918849081c330a2872e7e665cafa6d73ab522871f7e41aa2ed4c61bbed37e73363f19650dce16bbc1e472b3e4fe54c0177d97
7
+ data.tar.gz: 40fba5802be2359b85d201c2c732f8edac15f1e1be7ee17b2d59a39de9ac2f1ca6befd14d527fcacb4a5e763408c1296abe35301f8224a4b1138a5a4a1706096
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.6.0
@@ -52,10 +52,13 @@ module Scint
52
52
 
53
53
  cache = Scint::Cache::Layout.new
54
54
  bundle_path = @path || ENV["BUNDLER_PATH"] || ".bundle"
55
+ bundle_display = display_bundle_path(bundle_path)
55
56
  bundle_path = File.expand_path(bundle_path)
56
57
  worker_count = @jobs || [Platform.cpu_count * 2, 50].min
57
58
  compile_slots = compile_slots_for(worker_count)
58
59
  per_type_limits = install_task_limits(worker_count, compile_slots)
60
+ $stdout.puts "#{GREEN}💎#{RESET} Scintellating Gemfile into #{BOLD}#{bundle_display}#{RESET} #{DIM}(scint #{VERSION}, ruby #{RUBY_VERSION})#{RESET}"
61
+ $stdout.puts
59
62
 
60
63
  # 0. Build credential store from config files (~/.bundle/config, XDG scint/credentials)
61
64
  @credentials = Credentials.new
@@ -116,8 +119,9 @@ module Scint
116
119
 
117
120
  if to_install.empty?
118
121
  elapsed_ms = elapsed_ms_since(start_time)
122
+ worker_count = scheduler.stats[:workers]
119
123
  warn_missing_bundle_gitignore_entry
120
- $stdout.puts "\n#{GREEN}#{total_gems}#{RESET} gems installed total#{install_breakdown(cached: cached_gems, updated: updated_gems)}. #{DIM}(#{format_elapsed(elapsed_ms)})#{RESET}"
124
+ $stdout.puts "\n#{GREEN}#{total_gems}#{RESET} gems installed total#{install_breakdown(cached: cached_gems, updated: updated_gems)}. #{DIM}(#{format_run_footer(elapsed_ms, worker_count)})#{RESET}"
121
125
  return 0
122
126
  end
123
127
 
@@ -151,6 +155,7 @@ module Scint
151
155
  end
152
156
 
153
157
  elapsed_ms = elapsed_ms_since(start_time)
158
+ worker_count = stats[:workers]
154
159
  failed = errors.filter_map { |e| e[:name] }.uniq
155
160
  failed_count = failed.size
156
161
  failed_count = 1 if failed_count.zero? && stats[:failed] > 0
@@ -159,14 +164,14 @@ module Scint
159
164
 
160
165
  if has_failures
161
166
  warn_missing_bundle_gitignore_entry
162
- $stdout.puts "\n#{RED}Bundle failed!#{RESET} #{installed_total}/#{total_gems} gems installed total#{install_breakdown(cached: cached_gems, updated: updated_gems, compiled: compiled_gems, failed: failed_count)}. #{DIM}(#{format_elapsed(elapsed_ms)})#{RESET}"
167
+ $stdout.puts "\n#{RED}Bundle failed!#{RESET} #{installed_total}/#{total_gems} gems installed total#{install_breakdown(cached: cached_gems, updated: updated_gems, compiled: compiled_gems, failed: failed_count)}. #{DIM}(#{format_run_footer(elapsed_ms, worker_count)})#{RESET}"
163
168
  1
164
169
  else
165
170
  # 10. Write lockfile + runtime config only for successful installs
166
171
  write_lockfile(resolved, gemfile)
167
172
  write_runtime_config(resolved, bundle_path)
168
173
  warn_missing_bundle_gitignore_entry
169
- $stdout.puts "\n#{GREEN}#{total_gems}#{RESET} gems installed total#{install_breakdown(cached: cached_gems, updated: updated_gems, compiled: compiled_gems)}. #{DIM}(#{format_elapsed(elapsed_ms)})#{RESET}"
174
+ $stdout.puts "\n#{GREEN}#{total_gems}#{RESET} gems installed total#{install_breakdown(cached: cached_gems, updated: updated_gems, compiled: compiled_gems)}. #{DIM}(#{format_run_footer(elapsed_ms, worker_count)})#{RESET}"
170
175
  0
171
176
  end
172
177
  ensure
@@ -194,7 +199,7 @@ module Scint
194
199
  remote_uri: nil,
195
200
  checksum: nil,
196
201
  )
197
- resolved << scint_spec
202
+ resolved.unshift(scint_spec)
198
203
 
199
204
  resolved
200
205
  end
@@ -221,7 +226,7 @@ module Scint
221
226
  lib_dest = File.join(gem_dest, "lib")
222
227
  unless Dir.exist?(lib_dest)
223
228
  FS.mkdir_p(lib_dest)
224
- FS.hardlink_tree(scint_root, lib_dest)
229
+ FS.clone_tree(scint_root, lib_dest)
225
230
  end
226
231
 
227
232
  # Write gemspec
@@ -255,11 +260,12 @@ module Scint
255
260
  def clone_git_source(source, cache)
256
261
  return unless source.respond_to?(:uri)
257
262
  git_dir = cache.git_path(source.uri)
258
- return if Dir.exist?(git_dir)
263
+ if Dir.exist?(git_dir)
264
+ fetch_git_repo(git_dir)
265
+ return
266
+ end
259
267
 
260
- FS.mkdir_p(File.dirname(git_dir))
261
- system("git", "clone", "--bare", source.uri.to_s, git_dir,
262
- [:out, :err] => File::NULL)
268
+ clone_git_repo(source.uri, git_dir)
263
269
  end
264
270
 
265
271
  def resolve(gemfile, lockfile, cache)
@@ -376,8 +382,16 @@ module Scint
376
382
  end
377
383
 
378
384
  def lockfile_to_resolved(lockfile)
379
- lockfile.specs.map do |ls|
380
- source = ls[:source]
385
+ local_plat = Platform.local_platform
386
+
387
+ # Pick one best platform variant per gem+version from lockfile specs.
388
+ by_gem = Hash.new { |h, k| h[k] = [] }
389
+ lockfile.specs.each { |ls| by_gem[[ls[:name], ls[:version]]] << ls }
390
+
391
+ resolved = by_gem.map do |(_name, _version), specs|
392
+ best = pick_best_platform_spec(specs, local_plat)
393
+
394
+ source = best[:source]
381
395
  source_value =
382
396
  if source.is_a?(Source::Rubygems)
383
397
  source.uri.to_s
@@ -386,16 +400,96 @@ module Scint
386
400
  end
387
401
 
388
402
  ResolvedSpec.new(
389
- name: ls[:name],
390
- version: ls[:version],
391
- platform: ls[:platform],
392
- dependencies: ls[:dependencies],
403
+ name: best[:name],
404
+ version: best[:version],
405
+ platform: best[:platform],
406
+ dependencies: best[:dependencies],
393
407
  source: source_value,
394
408
  has_extensions: false,
395
409
  remote_uri: nil,
396
- checksum: ls[:checksum],
410
+ checksum: best[:checksum],
397
411
  )
398
412
  end
413
+
414
+ apply_locked_platform_preferences(resolved)
415
+ end
416
+
417
+ # Preference: exact platform match > compatible match > ruby > first.
418
+ def pick_best_platform_spec(specs, local_plat)
419
+ return specs.first if specs.size == 1
420
+
421
+ best = nil
422
+ best_score = -2
423
+
424
+ specs.each do |ls|
425
+ platform = ls[:platform] || "ruby"
426
+ if platform == "ruby"
427
+ score = 0
428
+ else
429
+ spec_plat = Gem::Platform.new(platform)
430
+ if spec_plat === local_plat
431
+ score = spec_plat.to_s == local_plat.to_s ? 2 : 1
432
+ else
433
+ score = -1
434
+ end
435
+ end
436
+
437
+ if score > best_score
438
+ best = ls
439
+ best_score = score
440
+ end
441
+ end
442
+
443
+ best
444
+ end
445
+
446
+ # Lockfiles can carry only the ruby variant for a gem version.
447
+ # Re-check compact index for the same locked version and upgrade to the
448
+ # best local platform variant when available.
449
+ def apply_locked_platform_preferences(resolved_specs)
450
+ preferred = preferred_platforms_for_locked_specs(resolved_specs)
451
+ return resolved_specs if preferred.empty?
452
+
453
+ resolved_specs.each do |spec|
454
+ key = "#{spec.name}-#{spec.version}"
455
+ platform = preferred[key]
456
+ next if platform.nil? || platform.empty?
457
+
458
+ spec.platform = platform
459
+ end
460
+
461
+ resolved_specs
462
+ end
463
+
464
+ def preferred_platforms_for_locked_specs(resolved_specs)
465
+ out = {}
466
+ by_source = resolved_specs
467
+ .select { |spec| rubygems_source_uri?(spec.source) }
468
+ .group_by { |spec| spec.source.to_s.chomp("/") }
469
+
470
+ by_source.each do |source_uri, specs|
471
+ begin
472
+ client = Index::Client.new(source_uri, credentials: @credentials)
473
+ provider = Resolver::Provider.new(client)
474
+ provider.prefetch(specs.map(&:name).uniq)
475
+
476
+ specs.each do |spec|
477
+ preferred = provider.preferred_platform_for(spec.name, Gem::Version.new(spec.version.to_s))
478
+ preferred = preferred.to_s
479
+ next if preferred.empty? || preferred == spec.platform.to_s
480
+
481
+ out["#{spec.name}-#{spec.version}"] = preferred
482
+ end
483
+ rescue StandardError
484
+ next
485
+ end
486
+ end
487
+
488
+ out
489
+ end
490
+
491
+ def rubygems_source_uri?(source)
492
+ source.is_a?(String) && source.match?(%r{\Ahttps?://})
399
493
  end
400
494
 
401
495
  def download_gem(entry, cache)
@@ -463,22 +557,37 @@ module Scint
463
557
  # and can't handle concurrent checkouts from the same repo.
464
558
  git_mutex_for(bare_repo).synchronize do
465
559
  clone_git_repo(uri, bare_repo) unless Dir.exist?(bare_repo)
560
+ fetch_git_repo(bare_repo)
561
+
562
+ resolved_revision = resolve_git_revision(bare_repo, revision)
466
563
 
467
564
  extracted = cache.extracted_path(spec)
468
- return if Dir.exist?(extracted)
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
469
569
 
470
570
  tmp = "#{extracted}.#{Process.pid}.#{Thread.current.object_id}.tmp"
471
571
  begin
472
572
  FileUtils.rm_rf(tmp)
473
- FS.mkdir_p(tmp)
474
-
475
- cmd = ["git", "--git-dir", bare_repo, "--work-tree", tmp, "checkout", "-f", revision, "--", "."]
476
- _out, err, status = Open3.capture3(*cmd)
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
+ )
477
584
  unless status.success?
478
- raise InstallError, "Git checkout failed for #{spec.name} (#{uri}@#{revision}): #{err.to_s.strip}"
585
+ raise InstallError, "Git checkout failed for #{spec.name} (#{uri}@#{resolved_revision}): #{err.to_s.strip}"
479
586
  end
480
587
 
588
+ FileUtils.rm_rf(extracted)
481
589
  FS.atomic_move(tmp, extracted)
590
+ FS.atomic_write(marker, "#{resolved_revision}\n")
482
591
  ensure
483
592
  FileUtils.rm_rf(tmp) if tmp && File.exist?(tmp)
484
593
  end
@@ -504,18 +613,47 @@ module Scint
504
613
 
505
614
  def clone_git_repo(uri, bare_repo)
506
615
  FS.mkdir_p(File.dirname(bare_repo))
507
- _out, err, status = Open3.capture3("git", "clone", "--bare", uri.to_s, bare_repo)
616
+ _out, err, status = git_capture3("clone", "--bare", uri.to_s, bare_repo)
508
617
  unless status.success?
509
618
  raise InstallError, "Git clone failed for #{uri}: #{err.to_s.strip}"
510
619
  end
511
620
  end
512
621
 
622
+ def fetch_git_repo(bare_repo)
623
+ _out, err, status = git_capture3(
624
+ "--git-dir", bare_repo,
625
+ "fetch",
626
+ "--prune",
627
+ "origin",
628
+ "+refs/heads/*:refs/heads/*",
629
+ "+refs/tags/*:refs/tags/*",
630
+ )
631
+ unless status.success?
632
+ raise InstallError, "Git fetch failed for #{bare_repo}: #{err.to_s.strip}"
633
+ end
634
+ end
635
+
636
+ def resolve_git_revision(bare_repo, revision)
637
+ out, err, status = git_capture3("--git-dir", bare_repo, "rev-parse", "#{revision}^{commit}")
638
+ unless status.success?
639
+ raise InstallError, "Unable to resolve git revision #{revision.inspect} in #{bare_repo}: #{err.to_s.strip}"
640
+ end
641
+ out.strip
642
+ end
643
+
644
+ def git_capture3(*args)
645
+ Open3.capture3("git", "-c", "core.fsmonitor=false", *args)
646
+ end
647
+
648
+ def git_checkout_marker_path(dir)
649
+ "#{dir}.scint_git_revision"
650
+ end
651
+
513
652
  def compile_slots_for(worker_count)
514
- # Keep one worker lane available for non-compile tasks and cap native
515
- # compiles at two concurrent jobs.
516
- max_compile = [2, Platform.cpu_count].min
517
- available = [worker_count - 1, 1].max
518
- [max_compile, available].min
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
519
657
  end
520
658
 
521
659
  def install_task_limits(worker_count, compile_slots)
@@ -531,6 +669,12 @@ module Scint
531
669
  }
532
670
  end
533
671
 
672
+ def display_bundle_path(path)
673
+ return path if path.start_with?("/", "./", "../")
674
+
675
+ "./#{path}"
676
+ end
677
+
534
678
  # Enqueue dependency-aware install tasks so compile/binstub can run
535
679
  # concurrently with link/download once prerequisites are satisfied.
536
680
  def enqueue_install_dag(scheduler, plan, cache, bundle_path, progress = nil, compile_slots: 1)
@@ -545,8 +689,8 @@ module Scint
545
689
  when :skip
546
690
  next
547
691
  when :builtin
548
- install_builtin_gem(entry, bundle_path)
549
- next
692
+ link_id = scheduler.enqueue(:link, entry.spec.name,
693
+ -> { install_builtin_gem(entry, bundle_path) })
550
694
  when :download
551
695
  key = spec_key(entry.spec)
552
696
  download_id = scheduler.enqueue(:download, entry.spec.name,
@@ -563,7 +707,7 @@ module Scint
563
707
  build_depends = (depends_on + dep_links).uniq
564
708
 
565
709
  extracted = extracted_path_for_entry(entry, cache)
566
- if Installer::ExtensionBuilder.buildable_source_dir?(extracted)
710
+ if Installer::ExtensionBuilder.needs_build?(entry.spec, extracted)
567
711
  build_id = scheduler.enqueue(:build_ext, entry.spec.name,
568
712
  -> { build_extensions(entry, cache, bundle_path, progress, compile_slots: compile_slots) },
569
713
  depends_on: build_depends)
@@ -649,7 +793,7 @@ module Scint
649
793
  enqueued = 0
650
794
  entries.each do |entry|
651
795
  extracted = extracted_path_for_entry(entry, cache)
652
- next unless Installer::ExtensionBuilder.buildable_source_dir?(extracted)
796
+ next unless Installer::ExtensionBuilder.needs_build?(entry.spec, extracted)
653
797
 
654
798
  scheduler.enqueue(:build_ext, entry.spec.name,
655
799
  -> { build_extensions(entry, cache, bundle_path, nil, compile_slots: compile_slots) })
@@ -663,10 +807,29 @@ module Scint
663
807
  if source_str.start_with?("/") && Dir.exist?(source_str)
664
808
  source_str
665
809
  else
666
- entry.cached_path || cache.extracted_path(entry.spec)
810
+ base = entry.cached_path || cache.extracted_path(entry.spec)
811
+ if git_source?(entry.spec.source) && Dir.exist?(base)
812
+ resolve_git_gem_subdir(base, entry.spec)
813
+ else
814
+ base
815
+ end
667
816
  end
668
817
  end
669
818
 
819
+ # For git monorepo sources, map gem name to its gemspec subdirectory.
820
+ def resolve_git_gem_subdir(repo_root, spec)
821
+ name = spec.name
822
+ return repo_root if File.exist?(File.join(repo_root, "#{name}.gemspec"))
823
+
824
+ source = spec.source
825
+ glob = source.respond_to?(:glob) ? source.glob : Source::Git::DEFAULT_GLOB
826
+ Dir.glob(File.join(repo_root, glob)).each do |path|
827
+ return File.dirname(path) if File.basename(path, ".gemspec") == name
828
+ end
829
+
830
+ repo_root
831
+ end
832
+
670
833
  def link_gem_files(entry, cache, bundle_path)
671
834
  spec = entry.spec
672
835
  extracted = extracted_path_for_entry(entry, cache)
@@ -680,14 +843,13 @@ module Scint
680
843
  from_cache: true,
681
844
  )
682
845
  Installer::Linker.link_files(prepared, bundle_path)
683
- Installer::Linker.link_files_to_ruby_dir(prepared, cache.install_ruby_dir)
684
846
  # If this gem has a cached native build, materialize it during link.
685
847
  # This lets reinstalling into a fresh .bundle skip build_ext entirely.
686
848
  Installer::ExtensionBuilder.link_cached_build(prepared, bundle_path, cache)
687
849
  end
688
850
 
689
851
  def build_extensions(entry, cache, bundle_path, progress = nil, compile_slots: 1)
690
- extracted = entry.cached_path || cache.extracted_path(entry.spec)
852
+ extracted = extracted_path_for_entry(entry, cache)
691
853
  gemspec = load_gemspec(extracted, entry.spec, cache)
692
854
 
693
855
  sync_build_env_dependencies(entry.spec, bundle_path, cache)
@@ -716,6 +878,8 @@ module Scint
716
878
  dep.name
717
879
  end
718
880
  end
881
+ dep_names << "rake"
882
+ dep_names.uniq!
719
883
  return if dep_names.empty?
720
884
 
721
885
  source_ruby_dir = File.join(bundle_path, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
@@ -734,7 +898,7 @@ module Scint
734
898
  next unless Dir.exist?(source_gem_dir)
735
899
 
736
900
  target_gem_dir = File.join(target_ruby_dir, "gems", full_name)
737
- FS.hardlink_tree(source_gem_dir, target_gem_dir) unless Dir.exist?(target_gem_dir)
901
+ FS.clone_tree(source_gem_dir, target_gem_dir) unless Dir.exist?(target_gem_dir)
738
902
 
739
903
  target_spec_dir = File.join(target_ruby_dir, "specifications")
740
904
  target_spec_path = File.join(target_spec_dir, "#{full_name}.gemspec")
@@ -990,6 +1154,12 @@ module Scint
990
1154
  "#{(elapsed_ms / 1000.0).round(2)}s"
991
1155
  end
992
1156
 
1157
+ def format_run_footer(elapsed_ms, worker_count)
1158
+ workers = worker_count.to_i
1159
+ noun = workers == 1 ? "worker" : "workers"
1160
+ "#{format_elapsed(elapsed_ms)}, #{workers} #{noun} used"
1161
+ end
1162
+
993
1163
  def warn_missing_bundle_gitignore_entry
994
1164
  path = ".gitignore"
995
1165
  return unless File.file?(path)
data/lib/scint/fs.rb CHANGED
@@ -32,6 +32,11 @@ module Scint
32
32
  return if system("cp", "-c", src, dst, [:out, :err] => File::NULL)
33
33
  end
34
34
 
35
+ # Try Linux reflink copy-on-write where supported (btrfs/xfs/etc).
36
+ if Platform.linux?
37
+ return if system("cp", "--reflink=always", src, dst, [:out, :err] => File::NULL)
38
+ end
39
+
35
40
  # Fallback: hardlink
36
41
  begin
37
42
  File.link(src, dst)
@@ -44,6 +49,30 @@ module Scint
44
49
  FileUtils.cp(src, dst)
45
50
  end
46
51
 
52
+ # Recursively clone directory tree from src_dir into dst_dir.
53
+ # On macOS/APFS, prefers CoW clones via `cp -cR`.
54
+ # Falls back to hardlink_tree, then regular copy per-file if needed.
55
+ def clone_tree(src_dir, dst_dir)
56
+ src_dir = src_dir.to_s
57
+ dst_dir = dst_dir.to_s
58
+ raise Errno::ENOENT, src_dir unless Dir.exist?(src_dir)
59
+ mkdir_p(dst_dir)
60
+
61
+ # Fast path on macOS/APFS: copy-on-write clone of full tree.
62
+ if Platform.macos?
63
+ src_contents = File.join(src_dir, ".")
64
+ return if system("cp", "-cR", src_contents, dst_dir, [:out, :err] => File::NULL)
65
+ end
66
+
67
+ # Fast path on Linux filesystems with reflink support.
68
+ if Platform.linux?
69
+ src_contents = File.join(src_dir, ".")
70
+ return if system("cp", "--reflink=always", "-R", src_contents, dst_dir, [:out, :err] => File::NULL)
71
+ end
72
+
73
+ hardlink_tree(src_dir, dst_dir)
74
+ end
75
+
47
76
  # Recursively hardlink all files from src_dir into dst_dir.
48
77
  # Directory structure is recreated; files are hardlinked.
49
78
  def hardlink_tree(src_dir, dst_dir)
@@ -68,10 +97,26 @@ module Scint
68
97
  end
69
98
 
70
99
  mkdir_p(File.dirname(dst_path))
100
+ # Another worker may have already materialized this file.
101
+ next if File.exist?(dst_path)
102
+
71
103
  begin
72
104
  File.link(src_path, dst_path)
105
+ rescue Errno::EEXIST
106
+ # Lost a race to another concurrent linker; destination is valid.
107
+ next
73
108
  rescue SystemCallError
74
- FileUtils.cp(src_path, dst_path)
109
+ # TOCTOU guard: destination may have appeared after File.link failed.
110
+ next if File.exist?(dst_path)
111
+
112
+ begin
113
+ clonefile(src_path, dst_path)
114
+ rescue StandardError
115
+ # If a concurrent worker created destination in the meantime,
116
+ # treat this as success; otherwise bubble up.
117
+ next if File.exist?(dst_path)
118
+ raise
119
+ end
75
120
  end
76
121
  end
77
122
  end
@@ -12,7 +12,7 @@ module Scint
12
12
  # Thread-safe. Uses ETag/Range for efficient updates.
13
13
  class Client
14
14
  ACCEPT_ENCODING = "gzip"
15
- USER_AGENT = "scint/0.1.0"
15
+ USER_AGENT = "scint/#{Scint::VERSION}"
16
16
  DEFAULT_TIMEOUT = 15
17
17
 
18
18
  attr_reader :source_uri
@@ -71,6 +71,41 @@ module Scint
71
71
  true
72
72
  end
73
73
 
74
+ # True when a gem has native extension sources that need compiling.
75
+ # Platform-specific gems usually ship precompiled binaries and should
76
+ # not be compiled from ext/ unless they lack support for this Ruby.
77
+ def needs_build?(spec, gem_dir)
78
+ platform = spec.respond_to?(:platform) ? spec.platform : nil
79
+ if platform && !platform.to_s.empty? && platform.to_s != "ruby"
80
+ return prebuilt_missing_for_ruby?(gem_dir) && buildable_source_dir?(gem_dir)
81
+ end
82
+
83
+ buildable_source_dir?(gem_dir)
84
+ end
85
+
86
+ # Detect versioned prebuilt extension folders like:
87
+ # lib/sqlite3/3.1, lib/sqlite3/3.2 ...
88
+ # If present, the current Ruby minor must exist or we need a build.
89
+ def prebuilt_missing_for_ruby?(gem_dir)
90
+ ruby_minor = RUBY_VERSION[/\d+\.\d+/]
91
+ lib_dir = File.join(gem_dir, "lib")
92
+ return false unless Dir.exist?(lib_dir)
93
+
94
+ Dir.children(lib_dir).each do |child|
95
+ child_path = File.join(lib_dir, child)
96
+ next unless File.directory?(child_path)
97
+
98
+ version_dirs = Dir.children(child_path).select do |entry|
99
+ File.directory?(File.join(child_path, entry)) && entry.match?(/\A\d+\.\d+\z/)
100
+ end
101
+ next if version_dirs.empty?
102
+
103
+ return true unless version_dirs.include?(ruby_minor)
104
+ end
105
+
106
+ false
107
+ end
108
+
74
109
  # --- private ---
75
110
 
76
111
  def buildable_source_dir?(gem_dir)
@@ -175,7 +210,7 @@ module Scint
175
210
  spec_full_name(spec))
176
211
  return if Dir.exist?(ext_install_dir)
177
212
 
178
- FS.hardlink_tree(cached_ext, ext_install_dir)
213
+ FS.clone_tree(cached_ext, ext_install_dir)
179
214
  end
180
215
 
181
216
  def build_env(gem_dir, build_ruby_dir, make_jobs)
@@ -258,7 +293,7 @@ module Scint
258
293
  private_class_method :find_extension_dirs, :compile_extension,
259
294
  :compile_extconf, :compile_cmake, :compile_rake,
260
295
  :find_rake_executable, :link_extensions, :build_env, :run_cmd,
261
- :spec_full_name, :ruby_install_dir
296
+ :spec_full_name, :ruby_install_dir, :prebuilt_missing_for_ruby?
262
297
  end
263
298
  end
264
299
  end
@@ -33,7 +33,7 @@ module Scint
33
33
  # 1. Link gem files into gems/{full_name}/
34
34
  gem_dest = File.join(ruby_dir, "gems", full_name)
35
35
  unless Dir.exist?(gem_dest)
36
- FS.hardlink_tree(prepared_gem.extracted_path, gem_dest)
36
+ FS.clone_tree(prepared_gem.extracted_path, gem_dest)
37
37
  end
38
38
 
39
39
  # 2. Write gemspec into specifications/
@@ -23,11 +23,12 @@ module Scint
23
23
  plan_one(spec, ruby_dir, cache_layout)
24
24
  end
25
25
 
26
- # Stable partition: downloads first (bigsmall), then everything else
27
- downloads, rest = entries.partition { |e| e.action == :download }
26
+ # Keep built-ins first, then downloads (big->small), then the rest.
27
+ builtins, non_builtins = entries.partition { |e| e.action == :builtin }
28
+ downloads, rest = non_builtins.partition { |e| e.action == :download }
28
29
  downloads.sort_by! { |e| -(estimated_size(e.spec)) }
29
30
 
30
- downloads + rest
31
+ builtins + downloads + rest
31
32
  end
32
33
 
33
34
  def plan_one(spec, ruby_dir, cache_layout)
@@ -58,7 +59,7 @@ module Scint
58
59
  # Local path sources are linked directly from their source tree.
59
60
  local_source = local_source_path(spec)
60
61
  if local_source
61
- action = ExtensionBuilder.buildable_source_dir?(local_source) ? :build_ext : :link
62
+ action = ExtensionBuilder.needs_build?(spec, local_source) ? :build_ext : :link
62
63
  return PlanEntry.new(spec: spec, action: action, cached_path: local_source, gem_path: gem_path)
63
64
  end
64
65
 
@@ -75,7 +76,7 @@ module Scint
75
76
 
76
77
  def needs_ext_build?(spec, cache_layout)
77
78
  extracted = cache_layout.extracted_path(spec)
78
- return false unless ExtensionBuilder.buildable_source_dir?(extracted)
79
+ return false unless ExtensionBuilder.needs_build?(spec, extracted)
79
80
 
80
81
  !ExtensionBuilder.cached_build_available?(spec, cache_layout)
81
82
  end
@@ -83,7 +84,7 @@ module Scint
83
84
  def extension_link_missing?(spec, ruby_dir, cache_layout)
84
85
  extracted = cache_layout.extracted_path(spec)
85
86
  return false unless Dir.exist?(extracted)
86
- return false unless ExtensionBuilder.buildable_source_dir?(extracted)
87
+ return false unless ExtensionBuilder.needs_build?(spec, extracted)
87
88
 
88
89
  full = cache_layout.full_name(spec)
89
90
  ext_install_dir = File.join(
@@ -6,7 +6,7 @@ module Scint
6
6
  # Produces output compatible with stock bundler.
7
7
  #
8
8
  # Sections in order: source blocks (GEM/GIT/PATH), PLATFORMS,
9
- # DEPENDENCIES, CHECKSUMS (if present), RUBY VERSION, BUNDLED WITH.
9
+ # DEPENDENCIES, CHECKSUMS (if present), RUBY VERSION.
10
10
  class Writer
11
11
  def self.write(lockfile_data)
12
12
  new(lockfile_data).generate
@@ -24,7 +24,6 @@ module Scint
24
24
  add_dependencies(out)
25
25
  add_checksums(out)
26
26
  add_ruby_version(out)
27
- add_bundled_with(out)
28
27
 
29
28
  out
30
29
  end
@@ -168,11 +167,6 @@ module Scint
168
167
  out << " #{@data.ruby_version}\n"
169
168
  end
170
169
 
171
- def add_bundled_with(out)
172
- return unless @data.bundler_version
173
- out << "\nBUNDLED WITH\n"
174
- out << " #{@data.bundler_version}\n"
175
- end
176
170
  end
177
171
  end
178
172
  end