scint 0.1.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,15 +18,22 @@ module Scint
18
18
  }.freeze
19
19
  BUILD_TAIL_MAX = 6
20
20
  BUILD_TAIL_PREVIEW_LINES = 4
21
- RENDER_HZ = 5
21
+ RENDER_HZ = 10
22
22
  RENDER_INTERVAL = 1.0 / RENDER_HZ
23
23
  MAX_DETAIL_ROWS_PER_PHASE = 4
24
24
  MAX_LINE_LEN = 220
25
25
  MIN_RENDER_WIDTH = 40
26
26
  MAX_PANEL_ROWS = 14
27
+ SLOW_OPERATION_THRESHOLD_SECONDS = 1.0
27
28
  SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
28
- IDLE_MARK = "".freeze
29
- PANEL_PHASE_ORDER = %i[download extract build_ext link].freeze
29
+ IDLE_MARK = "".freeze
30
+ PANEL_PHASE_ORDER = %i[download extract link build_ext].freeze
31
+ COMPLETION_LOG_TYPES = {
32
+ download: true,
33
+ extract: true,
34
+ link: true,
35
+ build_ext: true,
36
+ }.freeze
30
37
 
31
38
  PHASE_LABELS = {
32
39
  fetch_index: "Fetching index",
@@ -43,6 +50,12 @@ module Scint
43
50
  link: "Installing",
44
51
  build_ext: "Compiling",
45
52
  }.freeze
53
+ COMPLETION_LABELS = {
54
+ download: "Downloaded",
55
+ extract: "Extracted",
56
+ link: "Installed",
57
+ build_ext: "Compiled",
58
+ }.freeze
46
59
 
47
60
  def initialize(output: $stderr)
48
61
  @output = output
@@ -55,6 +68,7 @@ module Scint
55
68
  @failed = Hash.new(0)
56
69
  @total = Hash.new(0)
57
70
  @active_jobs = {}
71
+ @job_started_at = {}
58
72
  @build_tail = []
59
73
  @build_tail_by_name = {}
60
74
  @spinner_idx = 0
@@ -67,6 +81,7 @@ module Scint
67
81
  @phase_reserved_detail_rows = Hash.new(0)
68
82
  @setup_lines_printed = false
69
83
  @setup_gap_printed = false
84
+ @pending_log_lines = []
70
85
  @render_stop = false
71
86
  @render_thread = nil
72
87
  end
@@ -90,8 +105,9 @@ module Scint
90
105
 
91
106
  @mutex.synchronize do
92
107
  begin
108
+ flush_pending_logs_locked
93
109
  mark_completed_phase_elapsed_locked
94
- render_live_locked if any_stream_activity?
110
+ render_live_locked if any_active_stream_jobs?
95
111
  if @interactive && @live_rows.positive?
96
112
  move_cursor_down(@live_rows - 1)
97
113
  @output.print "\r\n"
@@ -119,11 +135,16 @@ module Scint
119
135
 
120
136
  @mutex.synchronize do
121
137
  @active_jobs[job_id] = { type: type, name: name }
138
+ @job_started_at[job_id] = Process.clock_gettime(Process::CLOCK_MONOTONIC) if completion_log_type?(type)
122
139
  @started += 1
123
140
  emit_setup_gap_if_needed(type)
124
141
  @phase_started_at[type] ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) if stream_type?(type)
125
- if @interactive && stream_type?(type)
142
+ if @interactive
126
143
  start_render_thread_locked
144
+ unless stream_type?(type)
145
+ clear_live_block_locked
146
+ emit_setup_or_log_line(type, name)
147
+ end
127
148
  else
128
149
  clear_live_block_locked
129
150
  emit_setup_or_log_line(type, name)
@@ -136,6 +157,8 @@ module Scint
136
157
  @completed[type] += 1
137
158
  active = @active_jobs.delete(job_id)
138
159
  @build_tail_by_name.delete(active[:name]) if active
160
+ elapsed = consume_job_elapsed(job_id)
161
+ emit_task_completion_locked(type, name, elapsed) if @interactive
139
162
  if !@interactive || !stream_type?(type)
140
163
  emit_phase_completion_locked(type)
141
164
  end
@@ -147,11 +170,22 @@ module Scint
147
170
 
148
171
  @mutex.synchronize do
149
172
  active = @active_jobs.delete(job_id)
173
+ elapsed = consume_job_elapsed(job_id)
150
174
  @build_tail_by_name.delete(active[:name]) if active
151
175
  @failed[type] += 1
152
- clear_live_block_locked
153
176
  label = PHASE_LABELS[type] || type.to_s
154
- @output.puts "#{RED}FAILED#{RESET} #{label} #{BOLD}#{name}#{RESET}: #{error.message}"
177
+ failed_timing = if elapsed && elapsed >= SLOW_OPERATION_THRESHOLD_SECONDS
178
+ " #{DIM}#{format_phase_elapsed(elapsed)}#{RESET}"
179
+ else
180
+ ""
181
+ end
182
+ failed_line = "#{RED}FAILED#{RESET} #{label} #{BOLD}#{name}#{RESET}: #{error.message}#{failed_timing}"
183
+ if @interactive
184
+ queue_log_line_locked(failed_line)
185
+ else
186
+ clear_live_block_locked
187
+ @output.puts failed_line
188
+ end
155
189
  if !@interactive
156
190
  emit_phase_completion_locked(type)
157
191
  end
@@ -197,9 +231,68 @@ module Scint
197
231
  HIDDEN_TYPES[type] == true
198
232
  end
199
233
 
234
+ def completion_log_type?(type)
235
+ COMPLETION_LOG_TYPES[type] == true
236
+ end
237
+
238
+ def consume_job_elapsed(job_id)
239
+ started_at = @job_started_at.delete(job_id)
240
+ return nil unless started_at
241
+
242
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
243
+ end
244
+
245
+ def emit_task_completion_locked(type, name, elapsed)
246
+ return unless completion_log_type?(type)
247
+
248
+ label = COMPLETION_LABELS[type] || (PHASE_LABELS[type] || type.to_s)
249
+ timing = if elapsed && elapsed >= SLOW_OPERATION_THRESHOLD_SECONDS
250
+ " #{DIM}#{format_phase_elapsed(elapsed)}#{RESET}"
251
+ else
252
+ ""
253
+ end
254
+ line = "#{GREEN}#{IDLE_MARK}#{RESET} #{DIM}#{label} #{BOLD}#{name}#{RESET}#{timing}"
255
+
256
+ queue_log_line_locked(line)
257
+ end
258
+
259
+ def queue_log_line_locked(line)
260
+ @pending_log_lines << line
261
+ end
262
+
263
+ def flush_pending_logs_locked
264
+ return if @pending_log_lines.empty?
265
+
266
+ lines = @pending_log_lines.dup
267
+ @pending_log_lines.clear
268
+ lines.each_with_index do |line, idx|
269
+ write_scrollback_line_locked(line, flush: idx == lines.length - 1)
270
+ end
271
+ end
272
+
273
+ def write_scrollback_line_locked(line, flush: true)
274
+ rendered = fit_line(line, @render_width)
275
+
276
+ unless @interactive && @live_rows.positive?
277
+ @output.print "\r"
278
+ @output.puts rendered
279
+ @output.flush if flush && @output.respond_to?(:flush)
280
+ return
281
+ end
282
+
283
+ # Insert one scrollback line above the active block without clearing
284
+ # existing rows first. This reduces visible flicker during fast updates.
285
+ move_cursor_up(@live_rows - 1)
286
+ @output.print "\e[1L"
287
+ @output.print "\r#{rendered}\e[K\n"
288
+ move_cursor_down(@live_rows - 1)
289
+ @output.print "\r"
290
+ @output.flush if flush && @output.respond_to?(:flush)
291
+ end
292
+
200
293
  def render_live_locked
201
294
  return unless @interactive
202
- return unless any_stream_activity?
295
+ return unless any_active_stream_jobs? || any_active_setup_jobs?
203
296
 
204
297
  mark_completed_phase_elapsed_locked
205
298
 
@@ -207,7 +300,11 @@ module Scint
207
300
  spinner = SPINNER_FRAMES[@spinner_idx % SPINNER_FRAMES.length]
208
301
  @spinner_idx += 1
209
302
 
210
- lines.concat(phase_lines(spinner))
303
+ if any_active_stream_jobs?
304
+ lines.concat(phase_lines(spinner))
305
+ else
306
+ lines << "#{GREEN}#{spinner}#{RESET} Processing..."
307
+ end
211
308
  lines = clamp_panel_rows(lines)
212
309
 
213
310
  redraw_live_block_locked(lines)
@@ -238,7 +335,8 @@ module Scint
238
335
  end
239
336
 
240
337
  def phase_lines(spinner)
241
- PANEL_PHASE_ORDER.flat_map do |type|
338
+ active_types = PANEL_PHASE_ORDER.select { |type| !active_stream_jobs_for(type).empty? }
339
+ active_types.flat_map do |type|
242
340
  phase_rows_for(type, spinner)
243
341
  end
244
342
  end
@@ -252,69 +350,21 @@ module Scint
252
350
  label = PHASE_SUMMARY_LABELS[type] || (PHASE_LABELS[type] || type.to_s)
253
351
  line = phase_status_line(type, active, label, completed, total, spinner)
254
352
  detail_rows = phase_detail_rows_for(type, active)
255
- [line, *reserve_phase_detail_space(type, detail_rows, total, completed, active)]
353
+ [line, *detail_rows.first(MAX_DETAIL_ROWS_PER_PHASE)]
256
354
  end
257
355
 
258
356
  def phase_status_line(type, active_jobs, label, completed, total, spinner)
259
357
  base = "#{label}... (#{completed}/#{total})"
260
- if active_jobs.empty?
261
- elapsed = @phase_elapsed[type]
262
- elapsed_text = elapsed ? " · #{format_phase_elapsed(elapsed)}" : ""
263
- "#{DIM}#{IDLE_MARK} #{base}#{elapsed_text}#{RESET}"
264
- else
265
- name = active_jobs.first[:name]
266
- "#{spinner} #{base} · #{BOLD}#{name}#{RESET}"
267
- end
358
+ name = active_jobs.first[:name]
359
+ "#{GREEN}#{spinner}#{RESET} #{base} · #{BOLD}#{name}#{RESET}"
268
360
  end
269
361
 
270
362
  def phase_detail_rows_for(type, active_jobs)
271
363
  return [] if active_jobs.empty?
272
364
 
273
- if type == :build_ext
274
- compile_tail_rows_for(active_jobs.first[:name])
275
- else
276
- active_rows_for(type, active_jobs)
277
- end
278
- end
279
-
280
- def reserve_phase_detail_space(type, details, total, completed, active_jobs)
281
- if phase_complete?(type, total, completed, active_jobs)
282
- @phase_reserved_detail_rows[type] = 0
283
- return []
284
- end
285
-
286
- desired = [details.length, MAX_DETAIL_ROWS_PER_PHASE].min
287
- @phase_reserved_detail_rows[type] = [@phase_reserved_detail_rows[type], desired].max
288
- reserved = @phase_reserved_detail_rows[type]
365
+ return compile_tail_rows_for(active_jobs.first[:name]) if type == :build_ext
289
366
 
290
- rows = details.first(reserved)
291
- while rows.length < reserved
292
- rows << "#{DIM} #{RESET}"
293
- end
294
- rows
295
- end
296
-
297
- def phase_complete?(type, total, completed, active_jobs)
298
- return false if total.zero?
299
-
300
- if completed >= total && active_jobs.empty?
301
- mark_phase_elapsed_locked(type)
302
- return true
303
- end
304
-
305
- false
306
- end
307
-
308
- def active_rows_for(_type, active_jobs)
309
- return [] if active_jobs.empty?
310
- if active_jobs.length <= MAX_DETAIL_ROWS_PER_PHASE
311
- return active_jobs.map { |job| " #{BOLD}#{job[:name]}#{RESET}" }
312
- end
313
-
314
- rows = active_jobs.first(MAX_DETAIL_ROWS_PER_PHASE - 1).map { |job| " #{BOLD}#{job[:name]}#{RESET}" }
315
- remaining = active_jobs.length - rows.length
316
- rows << "#{DIM} +#{remaining} more#{RESET}"
317
- rows
367
+ []
318
368
  end
319
369
 
320
370
  def compile_tail_rows_for(name)
@@ -330,14 +380,21 @@ module Scint
330
380
  @active_jobs.values.select { |job| job[:type] == type }
331
381
  end
332
382
 
383
+ def any_active_stream_jobs?
384
+ STREAM_TYPES.keys.any? { |type| !active_stream_jobs_for(type).empty? }
385
+ end
386
+
387
+ def any_active_setup_jobs?
388
+ @active_jobs.values.any? { |job| SETUP_TYPES[job[:type]] == true }
389
+ end
390
+
333
391
  def clear_live_block_locked
334
392
  return unless @interactive
335
393
  return if @live_rows.zero?
336
394
 
337
395
  move_cursor_up(@live_rows - 1)
338
396
  @live_rows.times do |idx|
339
- prev_width = @rendered_widths[idx] || 0
340
- @output.print "\r#{' ' * prev_width}"
397
+ @output.print "\r\e[K"
341
398
  @output.print "\n" if idx < (@live_rows - 1)
342
399
  end
343
400
  move_cursor_up(@live_rows - 1)
@@ -350,7 +407,6 @@ module Scint
350
407
  def redraw_live_block_locked(lines)
351
408
  hide_cursor_locked
352
409
  previous_rows = @live_rows
353
- previous_widths = @rendered_widths.dup
354
410
 
355
411
  move_cursor_up(previous_rows - 1) if previous_rows.positive?
356
412
 
@@ -359,12 +415,8 @@ module Scint
359
415
 
360
416
  max_rows.times do |idx|
361
417
  line = idx < lines.length ? fit_line(lines[idx], @render_width) : ""
362
- prev_width = previous_widths[idx] || 0
363
418
  line_width = visible_width(line)
364
- pad_len = prev_width > line_width ? (prev_width - line_width) : 0
365
- pad = pad_len.positive? ? (" " * pad_len) : ""
366
-
367
- @output.print "\r#{line}#{pad}"
419
+ @output.print "\r#{line}\e[K"
368
420
  @output.print "\n" if idx < (max_rows - 1)
369
421
  new_widths << line_width
370
422
  end
@@ -405,7 +457,8 @@ module Scint
405
457
  @mutex.synchronize do
406
458
  should_stop = @render_stop
407
459
  unless should_stop
408
- render_live_locked
460
+ flush_pending_logs_locked
461
+ render_live_locked if any_active_stream_jobs?
409
462
  should_stop = render_done_locked?
410
463
  end
411
464
  end
@@ -502,7 +555,9 @@ module Scint
502
555
  end
503
556
 
504
557
  def render_done_locked?
505
- any_stream_activity? && !stream_active_or_pending?
558
+ !any_active_stream_jobs? && !any_active_setup_jobs? &&
559
+ @pending_log_lines.empty? &&
560
+ (!any_stream_activity? || !stream_active_or_pending?)
506
561
  end
507
562
 
508
563
  def format_phase_elapsed(seconds)
@@ -65,8 +65,9 @@ module Scint
65
65
  else
66
66
  entries = info_for(name)
67
67
  versions = {}
68
- entries.each do |_name, version, platform, _deps, _reqs|
68
+ entries.each do |_name, version, platform, _deps, reqs|
69
69
  next unless platform_match?(platform)
70
+ next unless requirements_match?(reqs)
70
71
  ver = Gem::Version.new(version)
71
72
  versions[version] ||= ver
72
73
  end
@@ -85,7 +86,7 @@ module Scint
85
86
  pg = @path_gems[name]
86
87
  deps = {}
87
88
  (pg[:dependencies] || []).each do |dep_name, dep_req_str|
88
- deps[dep_name] = Gem::Requirement.new(dep_req_str || ">= 0")
89
+ deps[dep_name] = build_requirement(dep_req_str)
89
90
  end
90
91
  deps
91
92
  else
@@ -93,11 +94,12 @@ module Scint
93
94
  entries = info_for(name)
94
95
  deps = {}
95
96
 
96
- entries.each do |_name, ver, platform, dep_hash, _reqs|
97
+ entries.each do |_name, ver, platform, dep_hash, reqs|
97
98
  next unless ver == version_str && platform_match?(platform)
99
+ next unless requirements_match?(reqs)
98
100
  dep_hash.each do |dep_name, dep_req_str|
99
101
  # Merge constraints from all matching platform entries
100
- req = Gem::Requirement.new(dep_req_str.split(", "))
102
+ req = build_requirement(dep_req_str)
101
103
  deps[dep_name] = if deps[dep_name]
102
104
  merge_requirements(deps[dep_name], req)
103
105
  else
@@ -119,8 +121,9 @@ module Scint
119
121
  version_str = version.to_s
120
122
  entries = info_for(name)
121
123
  has_platform_specific = false
122
- entries.each do |_name, ver, platform, _deps, _reqs|
124
+ entries.each do |_name, ver, platform, _deps, reqs|
123
125
  next unless ver == version_str
126
+ next unless requirements_match?(reqs)
124
127
  has_platform_specific = true if platform != "ruby"
125
128
  end
126
129
  has_platform_specific
@@ -132,8 +135,8 @@ module Scint
132
135
  return "ruby" if @path_gems.key?(name)
133
136
 
134
137
  version_str = version.to_s
135
- entries = info_for(name).select do |_n, ver, platform, _deps, _reqs|
136
- ver == version_str && platform_match?(platform)
138
+ entries = info_for(name).select do |_n, ver, platform, _deps, reqs|
139
+ ver == version_str && platform_match?(platform) && requirements_match?(reqs)
137
140
  end
138
141
  return "ruby" if entries.empty?
139
142
 
@@ -225,6 +228,67 @@ module Scint
225
228
  combined = req1.requirements + req2.requirements
226
229
  Gem::Requirement.new(combined.map { |op, v| "#{op} #{v}" })
227
230
  end
231
+
232
+ def requirements_match?(reqs)
233
+ return true unless reqs.is_a?(Hash)
234
+
235
+ ruby_req = reqs["ruby"] || reqs[:ruby]
236
+ return false unless requirement_satisfied?(ruby_req, Gem::Version.new(RUBY_VERSION), ignore_upper: ignore_ruby_upper_bounds?) if ruby_req
237
+
238
+ rubygems_req = reqs["rubygems"] || reqs[:rubygems]
239
+ return false unless requirement_satisfied?(rubygems_req, Gem::Version.new(Gem::VERSION), ignore_upper: false) if rubygems_req
240
+
241
+ true
242
+ rescue StandardError
243
+ # Keep resolver tolerant of malformed compact-index requirement fields.
244
+ true
245
+ end
246
+
247
+ def ignore_ruby_upper_bounds?
248
+ raw = ENV["SCINT_IGNORE_RUBY_UPPER_BOUNDS"]
249
+ return true if raw.nil?
250
+
251
+ !%w[0 false no off].include?(raw.strip.downcase)
252
+ end
253
+
254
+ def requirement_satisfied?(requirement_str, current_version, ignore_upper:)
255
+ req = normalize_requirement(requirement_str, ignore_upper: ignore_upper)
256
+ req.satisfied_by?(current_version)
257
+ end
258
+
259
+ def normalize_requirement(requirement_str, ignore_upper:)
260
+ parts = requirement_str.to_s.split(",").map(&:strip).reject(&:empty?)
261
+ req = Gem::Requirement.new(*(parts.empty? ? [">= 0"] : parts))
262
+ return req unless ignore_upper
263
+
264
+ filtered = req.requirements.filter_map do |op, version|
265
+ case op
266
+ when "<", "<="
267
+ nil
268
+ when "~>"
269
+ [">=", version]
270
+ else
271
+ [op, version]
272
+ end
273
+ end
274
+
275
+ return Gem::Requirement.default if filtered.empty?
276
+
277
+ Gem::Requirement.new(filtered.map { |op, version| "#{op} #{version}" })
278
+ end
279
+
280
+ def build_requirement(value)
281
+ case value
282
+ when Gem::Requirement
283
+ value
284
+ when Array
285
+ parts = value.flatten.compact.map(&:to_s).reject(&:empty?)
286
+ Gem::Requirement.new(*(parts.empty? ? [">= 0"] : parts))
287
+ else
288
+ parts = value.to_s.split(",").map(&:strip).reject(&:empty?)
289
+ Gem::Requirement.new(*(parts.empty? ? [">= 0"] : parts))
290
+ end
291
+ end
228
292
  end
229
293
  end
230
294
  end
@@ -2,8 +2,10 @@
2
2
 
3
3
  require_relative "setup"
4
4
  require_relative "../fs"
5
+ require_relative "../platform"
5
6
  require "base64"
6
7
  require "pathname"
8
+ require "rbconfig"
7
9
 
8
10
  module Scint
9
11
  module Runtime
@@ -19,40 +21,40 @@ module Scint
19
21
  # lock_path: path to .bundle/scint.lock.marshal
20
22
  def exec(command, args, lock_path)
21
23
  original_env = ENV.to_hash
22
- lock_data = Setup.load_lock(lock_path)
24
+ Setup.load_lock(lock_path)
25
+ command, args = rewrite_bundle_exec(command, args)
26
+ passthrough_bundle = bundle_command?(command)
23
27
 
24
28
  bundle_dir = File.dirname(lock_path)
25
29
  scint_lib_dir = File.expand_path("../..", __dir__)
26
- ruby_dir = File.join(bundle_dir, "ruby",
27
- RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
28
-
29
- # Collect all load paths from the runtime config
30
- paths = []
31
- lock_data.each_value do |info|
32
- Array(info[:load_paths]).each do |p|
33
- paths << p if File.directory?(p)
34
- end
30
+ ruby_dir = Platform.ruby_install_dir(bundle_dir)
31
+
32
+ # Set RUBYLIB to make our Bundler shim loadable. We intentionally avoid
33
+ # injecting all gem load paths here because large apps can exceed exec
34
+ # argument/environment limits when RUBYLIB gets too long.
35
+ # Gem load paths are still activated via Scint::Runtime::Setup from
36
+ # `-rbundler/setup`.
37
+ unless passthrough_bundle
38
+ existing = ENV["RUBYLIB"]
39
+ rubylib = scint_lib_dir
40
+ rubylib = "#{rubylib}#{File::PATH_SEPARATOR}#{existing}" if existing && !existing.empty?
41
+ ENV["RUBYLIB"] = rubylib
35
42
  end
36
43
 
37
- # Ensure our bundler shim wins over global bundler.
38
- # Order matters: scint lib first, then gem load paths.
39
- paths.unshift(scint_lib_dir)
40
-
41
- # Set RUBYLIB so the child process inherits load paths.
42
- existing = ENV["RUBYLIB"]
43
- rubylib = paths.join(File::PATH_SEPARATOR)
44
- rubylib = "#{rubylib}#{File::PATH_SEPARATOR}#{existing}" if existing && !existing.empty?
45
- ENV["RUBYLIB"] = rubylib
46
-
47
44
  ENV["SCINT_RUNTIME_LOCK"] = lock_path
48
45
  ENV["GEM_HOME"] = ruby_dir
49
- ENV["GEM_PATH"] = ruby_dir
46
+ ENV["GEM_PATH"] = build_gem_path(ruby_dir, original_env["GEM_PATH"])
50
47
  ENV["BUNDLE_PATH"] = bundle_dir
51
48
  ENV["BUNDLE_APP_CONFIG"] = bundle_dir
52
49
  ENV["BUNDLE_GEMFILE"] = find_gemfile(bundle_dir)
53
- ENV["PATH"] = prepend_path(File.join(ruby_dir, "bin"), ENV["PATH"])
50
+ ruby_interpreter_bin = File.dirname(RbConfig.ruby)
51
+
52
+ # Keep interpreter/bin ahead of .bundle/bin so `#!/usr/bin/env ruby`
53
+ # resolves to the interpreter, not a gem-provided "ruby" executable.
54
54
  ENV["PATH"] = prepend_path(File.join(bundle_dir, "bin"), ENV["PATH"])
55
- prepend_rubyopt("-rbundler/setup")
55
+ ENV["PATH"] = prepend_path(File.join(ruby_dir, "bin"), ENV["PATH"])
56
+ ENV["PATH"] = prepend_path(ruby_interpreter_bin, ENV["PATH"])
57
+ prepend_rubyopt("-rbundler/setup") unless passthrough_bundle
56
58
  export_original_env(original_env)
57
59
 
58
60
  command = resolve_command(command, bundle_dir, ruby_dir)
@@ -76,9 +78,9 @@ module Scint
76
78
 
77
79
  def prepend_path(prefix, current_path)
78
80
  return prefix unless current_path && !current_path.empty?
79
- return current_path if current_path.split(File::PATH_SEPARATOR).include?(prefix)
80
-
81
- "#{prefix}#{File::PATH_SEPARATOR}#{current_path}"
81
+ parts = current_path.split(File::PATH_SEPARATOR).reject(&:empty?)
82
+ parts.delete(prefix)
83
+ ([prefix] + parts).join(File::PATH_SEPARATOR)
82
84
  end
83
85
 
84
86
  def export_original_env(original_env)
@@ -87,6 +89,29 @@ module Scint
87
89
  # Non-fatal: shim can fallback to current ENV.
88
90
  end
89
91
 
92
+ def build_gem_path(bundle_ruby_dir, original_gem_path)
93
+ paths = [bundle_ruby_dir]
94
+ if defined?(Gem) && Gem.respond_to?(:default_path)
95
+ paths.concat(Array(Gem.default_path))
96
+ end
97
+ if original_gem_path && !original_gem_path.empty?
98
+ paths.concat(original_gem_path.split(File::PATH_SEPARATOR))
99
+ end
100
+ paths.reject(&:empty?).uniq.join(File::PATH_SEPARATOR)
101
+ end
102
+
103
+ def bundle_command?(command)
104
+ File.basename(command.to_s) == "bundle"
105
+ end
106
+
107
+ def rewrite_bundle_exec(command, args)
108
+ return [command, args] unless bundle_command?(command)
109
+ return [command, args] unless args.first == "exec"
110
+ return [command, args] if args.length < 2
111
+
112
+ [args[1], args[2..] || []]
113
+ end
114
+
90
115
  def resolve_command(command, bundle_dir, ruby_dir)
91
116
  return command if command.include?(File::SEPARATOR)
92
117
 
@@ -135,6 +160,7 @@ module Scint
135
160
  end
136
161
 
137
162
  private_class_method :find_gemfile, :prepend_rubyopt, :prepend_path, :export_original_env,
163
+ :bundle_command?, :rewrite_bundle_exec, :build_gem_path,
138
164
  :resolve_command, :find_gem_executable, :write_bundle_exec_wrapper
139
165
  end
140
166
  end
@@ -29,6 +29,7 @@ module Scint
29
29
  $LOAD_PATH.unshift(*paths)
30
30
 
31
31
  ENV["BUNDLE_GEMFILE"] ||= find_gemfile(File.dirname(lock_path))
32
+ hydrate_loaded_specs(lock_data)
32
33
 
33
34
  lock_data
34
35
  end
@@ -39,7 +40,34 @@ module Scint
39
40
  File.exist?(gemfile) ? gemfile : nil
40
41
  end
41
42
 
42
- private_class_method :find_gemfile
43
+ def hydrate_loaded_specs(lock_data)
44
+ return unless defined?(Gem) && Gem.respond_to?(:loaded_specs)
45
+
46
+ lock_data.each do |name, info|
47
+ gem_name = name.to_s
48
+ next if gem_name.empty? || Gem.loaded_specs[gem_name]
49
+
50
+ version = info.is_a?(Hash) ? info[:version] : nil
51
+ spec = find_installed_spec(gem_name, version)
52
+ Gem.loaded_specs[gem_name] = spec if spec
53
+ end
54
+ rescue StandardError
55
+ # Best-effort compatibility with gems expecting Gem.loaded_specs.
56
+ end
57
+
58
+ def find_installed_spec(gem_name, version)
59
+ version_req = version.to_s.strip
60
+ if !version_req.empty?
61
+ exact = Gem::Specification.find_all_by_name(gem_name, version_req)
62
+ return exact.find { |spec| spec.version.to_s == version_req } || exact.first
63
+ end
64
+
65
+ Gem::Specification.find_all_by_name(gem_name).max_by(&:version)
66
+ rescue StandardError
67
+ nil
68
+ end
69
+
70
+ private_class_method :find_gemfile, :hydrate_loaded_specs, :find_installed_spec
43
71
  end
44
72
  end
45
73
  end
@@ -329,7 +329,12 @@ module Scint
329
329
  job.error = error
330
330
  @failed[job.id] = job
331
331
  @errors << { job_id: job.id, type: job.type, name: job.name, error: error }
332
- @aborted = true if @fail_fast
332
+ if @fail_fast
333
+ @aborted = true
334
+ # Drop queued work immediately; wait_all will return once current
335
+ # in-flight jobs drain.
336
+ @pending.clear
337
+ end
333
338
  else
334
339
  job.state = :completed
335
340
  @completed[job.id] = job