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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "extension_builder"
4
4
  require_relative "../platform"
5
+ require_relative "../cache/validity"
5
6
 
6
7
  module Scint
7
8
  module Installer
@@ -12,16 +13,16 @@ module Scint
12
13
  # Compare resolved specs against what's already installed.
13
14
  # Returns an Array of PlanEntry with action set to one of:
14
15
  # :skip — already installed in bundle_path
15
- # :link — extracted in global cache, just needs linking
16
+ # :link — cached in global cache, just needs linking
16
17
  # :download — needs downloading from remote
17
18
  # :build_ext — has native extensions that need compiling
18
19
  #
19
20
  # Download entries are sorted largest-first so big gems start early,
20
21
  # keeping the pipeline saturated while small gems fill in gaps.
21
- def plan(resolved_specs, bundle_path, cache_layout)
22
+ def plan(resolved_specs, bundle_path, cache_layout, telemetry: nil)
22
23
  ruby_dir = Platform.ruby_install_dir(bundle_path)
23
24
  entries = resolved_specs.map do |spec|
24
- plan_one(spec, ruby_dir, cache_layout)
25
+ plan_one(spec, ruby_dir, cache_layout, telemetry: telemetry)
25
26
  end
26
27
 
27
28
  # Keep built-ins first, then downloads (big->small), then the rest.
@@ -32,7 +33,7 @@ module Scint
32
33
  builtins + downloads + rest
33
34
  end
34
35
 
35
- def plan_one(spec, ruby_dir, cache_layout)
36
+ def plan_one(spec, ruby_dir, cache_layout, telemetry: nil)
36
37
  full = cache_layout.full_name(spec)
37
38
  gem_path = File.join(ruby_dir, "gems", full)
38
39
  spec_path = File.join(ruby_dir, "specifications", "#{full}.gemspec")
@@ -48,10 +49,12 @@ module Scint
48
49
 
49
50
  # Already installed? Require both gem files and specification.
50
51
  if Dir.exist?(gem_path) && File.exist?(spec_path)
51
- if extension_link_missing?(spec, ruby_dir, cache_layout)
52
- extracted = cache_layout.extracted_path(spec)
52
+ cache_source = Cache::Validity.source_path_for(spec, cache_layout, telemetry: telemetry)
53
+ if extension_link_missing?(spec, ruby_dir, cache_layout, cache_source)
53
54
  action = ExtensionBuilder.cached_build_available?(spec, cache_layout) ? :link : :build_ext
54
- return PlanEntry.new(spec: spec, action: action, cached_path: extracted, gem_path: gem_path)
55
+ return PlanEntry.new(spec: spec, action: action, cached_path: cache_source, gem_path: gem_path) if cache_source
56
+
57
+ return PlanEntry.new(spec: spec, action: :download, cached_path: nil, gem_path: gem_path)
55
58
  end
56
59
 
57
60
  return PlanEntry.new(spec: spec, action: :skip, cached_path: nil, gem_path: gem_path)
@@ -64,39 +67,32 @@ module Scint
64
67
  return PlanEntry.new(spec: spec, action: action, cached_path: local_source, gem_path: gem_path)
65
68
  end
66
69
 
67
- # Extracted in global cache?
68
- extracted = cache_layout.extracted_path(spec)
69
- if Dir.exist?(extracted)
70
- action = needs_ext_build?(spec, cache_layout) ? :build_ext : :link
71
- return PlanEntry.new(spec: spec, action: action, cached_path: extracted, gem_path: gem_path)
70
+ cache_source = Cache::Validity.source_path_for(spec, cache_layout, telemetry: telemetry)
71
+ if cache_source
72
+ action = needs_ext_build?(spec, cache_layout, cache_source) ? :build_ext : :link
73
+ return PlanEntry.new(spec: spec, action: action, cached_path: cache_source, gem_path: gem_path)
72
74
  end
73
75
 
74
76
  # Needs downloading
75
77
  PlanEntry.new(spec: spec, action: :download, cached_path: nil, gem_path: gem_path)
76
78
  end
77
79
 
78
- def needs_ext_build?(spec, cache_layout)
79
- extracted = cache_layout.extracted_path(spec)
80
- return false unless ExtensionBuilder.needs_build?(spec, extracted)
80
+ def needs_ext_build?(spec, cache_layout, source_dir)
81
+ return false unless source_dir
82
+ return false unless ExtensionBuilder.needs_build?(spec, source_dir)
81
83
 
82
84
  !ExtensionBuilder.cached_build_available?(spec, cache_layout)
83
85
  end
84
86
 
85
- def extension_link_missing?(spec, ruby_dir, cache_layout)
86
- extracted = cache_layout.extracted_path(spec)
87
- return false unless Dir.exist?(extracted)
88
- return false unless ExtensionBuilder.needs_build?(spec, extracted)
87
+ def extension_link_missing?(spec, ruby_dir, cache_layout, source_dir)
88
+ return false unless source_dir
89
+ return false unless ExtensionBuilder.needs_build?(spec, source_dir)
89
90
 
90
91
  full = cache_layout.full_name(spec)
91
- ext_install_dir = File.join(
92
- ruby_dir,
93
- "extensions",
94
- Platform.gem_arch,
95
- Platform.extension_api_version,
96
- full,
97
- )
98
-
99
- !Dir.exist?(ext_install_dir)
92
+ gem_dir = File.join(ruby_dir, "gems", full)
93
+ marker = File.join(gem_dir, ExtensionBuilder::BUILD_MARKER)
94
+
95
+ !File.exist?(marker)
100
96
  end
101
97
 
102
98
  # Rough size estimate for download ordering.
@@ -4,9 +4,16 @@ require_relative "../downloader/pool"
4
4
  require_relative "../gem/package"
5
5
  require_relative "../gem/extractor"
6
6
  require_relative "../cache/layout"
7
+ require_relative "../cache/manifest"
8
+ require_relative "../cache/validity"
7
9
  require_relative "../fs"
8
10
  require_relative "../errors"
9
11
  require_relative "../spec_utils"
12
+ require_relative "../platform"
13
+ require_relative "./promoter"
14
+ require_relative "./extension_builder"
15
+ require_relative "../source/git"
16
+ require_relative "../source/path"
10
17
 
11
18
  module Scint
12
19
  module Installer
@@ -45,21 +52,30 @@ module Scint
45
52
  already_cached = []
46
53
 
47
54
  sorted.each do |entry|
48
- inbound = @layout.inbound_path(entry.spec)
49
- extracted = @layout.extracted_path(entry.spec)
55
+ spec = entry.spec
56
+ inbound = @layout.inbound_path(spec)
57
+ assembling = @layout.assembling_path(spec)
58
+ cached = @layout.cached_path(spec)
50
59
 
51
- if File.directory?(extracted)
52
- # Already extracted -- load gemspec from cache or re-read
53
- gemspec = load_cached_spec(entry.spec) || read_gemspec_from_extracted(extracted, entry.spec)
60
+ if Cache::Validity.cached_valid?(spec, @layout)
61
+ gemspec = load_cached_spec(spec) || read_gemspec_from_extracted(cached, spec)
54
62
  already_cached << PreparedGem.new(
55
- spec: entry.spec,
56
- extracted_path: extracted,
63
+ spec: spec,
64
+ extracted_path: cached,
57
65
  gemspec: gemspec,
58
66
  from_cache: true,
59
67
  )
68
+ elsif File.directory?(assembling)
69
+ gemspec = read_gemspec_from_extracted(assembling, spec)
70
+ already_cached << PreparedGem.new(
71
+ spec: spec,
72
+ extracted_path: assembling,
73
+ gemspec: gemspec,
74
+ from_cache: false,
75
+ )
60
76
  elsif File.exist?(inbound)
61
77
  # Downloaded but not extracted
62
- already_cached << extract_gem(entry.spec, inbound)
78
+ already_cached << extract_gem(spec, inbound)
63
79
  else
64
80
  # Need to download
65
81
  to_download << entry
@@ -105,65 +121,98 @@ module Scint
105
121
 
106
122
  # Prepare a single entry (for use with scheduler).
107
123
  def prepare_one(entry)
108
- inbound = @layout.inbound_path(entry.spec)
109
- extracted = @layout.extracted_path(entry.spec)
124
+ spec = entry.spec
125
+ inbound = @layout.inbound_path(spec)
126
+ assembling = @layout.assembling_path(spec)
127
+ cached = @layout.cached_path(spec)
110
128
 
111
- if File.directory?(extracted)
112
- gemspec = load_cached_spec(entry.spec) || read_gemspec_from_extracted(extracted, entry.spec)
129
+ if Cache::Validity.cached_valid?(spec, @layout)
130
+ gemspec = load_cached_spec(spec) || read_gemspec_from_extracted(cached, spec)
113
131
  return PreparedGem.new(
114
- spec: entry.spec,
115
- extracted_path: extracted,
132
+ spec: spec,
133
+ extracted_path: cached,
116
134
  gemspec: gemspec,
117
135
  from_cache: true,
118
136
  )
119
137
  end
120
138
 
139
+ if File.directory?(assembling)
140
+ gemspec = read_gemspec_from_extracted(assembling, spec)
141
+ return PreparedGem.new(
142
+ spec: spec,
143
+ extracted_path: assembling,
144
+ gemspec: gemspec,
145
+ from_cache: false,
146
+ )
147
+ end
148
+
149
+
121
150
  unless File.exist?(inbound)
122
151
  uri = gem_download_uri(entry)
123
152
  @download_pool.download(uri, inbound)
124
153
  end
125
154
 
126
- extract_gem(entry.spec, inbound)
155
+ extract_gem(spec, inbound)
127
156
  end
128
157
 
129
158
  private
130
159
 
131
160
  def extract_gem(spec, gem_path)
132
- dest = @layout.extracted_path(spec)
161
+ cached = @layout.cached_path(spec)
162
+ assembling = @layout.assembling_path(spec)
163
+
164
+ if Cache::Validity.cached_valid?(spec, @layout)
165
+ gemspec = load_cached_spec(spec) || read_gemspec_from_extracted(cached, spec)
166
+ return PreparedGem.new(spec: spec, extracted_path: cached, gemspec: gemspec, from_cache: true)
167
+ end
133
168
 
134
- if File.directory?(dest)
135
- gemspec = load_cached_spec(spec) || read_gemspec_from_extracted(dest, spec)
136
- return PreparedGem.new(spec: spec, extracted_path: dest, gemspec: gemspec, from_cache: true)
169
+ if File.directory?(assembling)
170
+ gemspec = read_gemspec_from_extracted(assembling, spec)
171
+ return PreparedGem.new(spec: spec, extracted_path: assembling, gemspec: gemspec, from_cache: false)
137
172
  end
138
173
 
139
- # Extract to temp dir, then atomic move
140
- tmp_dest = "#{dest}.#{Process.pid}.tmp"
174
+ # Extract to temp dir in assembling, then atomic move
175
+ tmp_dest = "#{assembling}.#{Process.pid}.tmp"
141
176
  FileUtils.rm_rf(tmp_dest) if File.exist?(tmp_dest)
142
177
 
143
178
  result = @package.extract(gem_path, tmp_dest)
144
- FS.atomic_move(tmp_dest, dest)
145
-
146
- # Cache the gemspec as Marshal for fast future loads
147
- cache_spec(spec, result[:gemspec])
179
+ FS.atomic_move(tmp_dest, assembling)
148
180
 
149
- PreparedGem.new(
150
- spec: spec,
151
- extracted_path: dest,
152
- gemspec: result[:gemspec],
153
- from_cache: false,
154
- )
181
+ if ExtensionBuilder.needs_build?(spec, assembling)
182
+ PreparedGem.new(
183
+ spec: spec,
184
+ extracted_path: assembling,
185
+ gemspec: result[:gemspec],
186
+ from_cache: false,
187
+ )
188
+ else
189
+ promote_assembled(spec, assembling, result[:gemspec])
190
+ PreparedGem.new(
191
+ spec: spec,
192
+ extracted_path: @layout.cached_path(spec),
193
+ gemspec: result[:gemspec],
194
+ from_cache: false,
195
+ )
196
+ end
155
197
  end
156
198
 
157
199
  def load_cached_spec(spec)
158
- path = @layout.spec_cache_path(spec)
200
+ path = @layout.cached_spec_path(spec)
159
201
  return nil unless File.exist?(path)
160
- Marshal.load(File.binread(path))
161
- rescue ArgumentError, TypeError, EOFError
202
+
203
+ data = File.binread(path)
204
+ if data.start_with?("---")
205
+ data.force_encoding("UTF-8") if data.encoding != Encoding::UTF_8
206
+ return Gem::Specification.from_yaml(data)
207
+ end
208
+
209
+ Marshal.load(data)
210
+ rescue ArgumentError, TypeError, EOFError, StandardError
162
211
  nil
163
212
  end
164
213
 
165
214
  def cache_spec(spec, gemspec)
166
- path = @layout.spec_cache_path(spec)
215
+ path = @layout.cached_spec_path(spec)
167
216
  FS.atomic_write(path, Marshal.dump(gemspec))
168
217
  rescue StandardError
169
218
  # Non-fatal: cache miss on next load
@@ -173,10 +222,91 @@ module Scint
173
222
  pattern = File.join(extracted_dir, "*.gemspec")
174
223
  candidates = Dir.glob(pattern)
175
224
  if candidates.any?
225
+ version = spec.respond_to?(:version) ? spec.version.to_s : nil
226
+ old_version = ENV["VERSION"]
176
227
  begin
177
- ::Gem::Specification.load(candidates.first)
178
- rescue StandardError
228
+ ENV["VERSION"] = version if version && !ENV["VERSION"]
229
+ SpecUtils.load_gemspec(candidates.first, isolate: true)
230
+ rescue SystemExit, StandardError
179
231
  nil
232
+ ensure
233
+ ENV["VERSION"] = old_version
234
+ end
235
+ end
236
+ end
237
+
238
+ def promote_assembled(spec, assembling_path, gemspec)
239
+ return unless assembling_path && Dir.exist?(assembling_path)
240
+
241
+ cached_dir = @layout.cached_path(spec)
242
+ promoter = Promoter.new(root: @layout.root)
243
+ lock_key = "#{Platform.abi_key}-#{@layout.full_name(spec)}"
244
+
245
+ promoter.validate_within_root!(@layout.root, assembling_path, label: "assembling")
246
+ promoter.validate_within_root!(@layout.root, cached_dir, label: "cached")
247
+
248
+ result = nil
249
+ promoter.with_staging_dir(prefix: "cached") do |staging|
250
+ FS.clone_tree(assembling_path, staging)
251
+ manifest = build_cached_manifest(spec, staging)
252
+ spec_payload = gemspec ? Marshal.dump(gemspec) : nil
253
+ result = promoter.promote_tree(
254
+ staging_path: staging,
255
+ target_path: cached_dir,
256
+ lock_key: lock_key,
257
+ )
258
+ if result == :promoted
259
+ write_cached_metadata(spec, spec_payload, manifest)
260
+ end
261
+ end
262
+ FileUtils.rm_rf(assembling_path) if Dir.exist?(assembling_path)
263
+ result
264
+ rescue StandardError
265
+ FileUtils.rm_rf(cached_dir) if Dir.exist?(cached_dir)
266
+ raise
267
+ end
268
+
269
+ def write_cached_metadata(spec, spec_payload, manifest)
270
+ spec_path = @layout.cached_spec_path(spec)
271
+ manifest_path = @layout.cached_manifest_path(spec)
272
+ FS.mkdir_p(File.dirname(spec_path))
273
+
274
+ FS.atomic_write(spec_path, spec_payload) if spec_payload
275
+ Cache::Manifest.write(manifest_path, manifest)
276
+ end
277
+
278
+ def build_cached_manifest(spec, cached_dir)
279
+ Cache::Manifest.build(
280
+ spec: spec,
281
+ gem_dir: cached_dir,
282
+ abi_key: Platform.abi_key,
283
+ source: manifest_source_for(spec),
284
+ extensions: ExtensionBuilder.needs_build?(spec, cached_dir),
285
+ )
286
+ end
287
+
288
+ def manifest_source_for(spec)
289
+ source = spec.source
290
+ if source.is_a?(Source::Git)
291
+ {
292
+ "type" => "git",
293
+ "uri" => source.uri.to_s,
294
+ "revision" => source.revision || source.ref || source.branch || source.tag,
295
+ }.compact
296
+ elsif source.is_a?(Source::Path)
297
+ {
298
+ "type" => "path",
299
+ "path" => File.expand_path(source.path.to_s),
300
+ "uri" => source.path.to_s,
301
+ }
302
+ else
303
+ source_str = source.to_s
304
+ if source_str.start_with?("http://", "https://")
305
+ { "type" => "rubygems", "uri" => source_str }
306
+ elsif source_str.start_with?("/", ".", "~")
307
+ { "type" => "path", "path" => File.expand_path(source_str), "uri" => source_str }
308
+ else
309
+ { "type" => "rubygems", "uri" => source_str }
180
310
  end
181
311
  end
182
312
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../fs"
5
+ require "fileutils"
6
+ require "securerandom"
7
+
8
+ module Scint
9
+ module Installer
10
+ class Promoter
11
+ attr_reader :root, :lock_dir, :staging_dir
12
+
13
+ def initialize(root:, lock_dir: nil, staging_dir: nil)
14
+ @root = File.expand_path(root.to_s)
15
+ @lock_dir = File.expand_path(lock_dir.to_s) if lock_dir
16
+ @staging_dir = File.expand_path(staging_dir.to_s) if staging_dir
17
+
18
+ @lock_dir ||= File.join(@root, "locks", "promotion")
19
+ @staging_dir ||= File.join(@root, "staging")
20
+ end
21
+
22
+ def with_lock(lock_key)
23
+ lock_path = lock_path_for(lock_key)
24
+ validate_within_root!(@root, lock_path, label: "lock")
25
+ FS.mkdir_p(File.dirname(lock_path))
26
+
27
+ File.open(lock_path, "w") do |file|
28
+ file.flock(File::LOCK_EX)
29
+ yield
30
+ ensure
31
+ file.flock(File::LOCK_UN) rescue nil
32
+ end
33
+ end
34
+
35
+ def with_staging_dir(prefix:)
36
+ FS.mkdir_p(@staging_dir)
37
+ staging_path = File.join(@staging_dir, staging_suffix(prefix))
38
+ validate_within_root!(@root, staging_path, label: "staging")
39
+ FileUtils.rm_rf(staging_path) if File.exist?(staging_path)
40
+
41
+ begin
42
+ FS.mkdir_p(staging_path)
43
+ yield staging_path
44
+ ensure
45
+ FileUtils.rm_rf(staging_path) if Dir.exist?(staging_path)
46
+ end
47
+ end
48
+
49
+ def promote_tree(staging_path:, target_path:, lock_key:)
50
+ validate_within_root!(@root, staging_path, label: "staging")
51
+ validate_within_root!(@root, target_path, label: "target")
52
+ raise CacheError, "Staging path does not exist: #{staging_path}" unless Dir.exist?(staging_path)
53
+
54
+ with_lock(lock_key) do
55
+ if Dir.exist?(target_path)
56
+ FileUtils.rm_rf(staging_path) if Dir.exist?(staging_path)
57
+ return :exists
58
+ end
59
+
60
+ begin
61
+ FS.atomic_move(staging_path, target_path)
62
+ rescue StandardError
63
+ FileUtils.rm_rf(staging_path) if Dir.exist?(staging_path)
64
+ raise
65
+ end
66
+
67
+ :promoted
68
+ end
69
+ end
70
+
71
+ def validate_within_root!(root_path, candidate_path, label: "path")
72
+ root_expanded = File.expand_path(root_path.to_s)
73
+ candidate_expanded = File.expand_path(candidate_path.to_s)
74
+ within_root = candidate_expanded == root_expanded ||
75
+ candidate_expanded.start_with?("#{root_expanded}/")
76
+ return if within_root
77
+
78
+ raise CacheError, "#{label.capitalize} escapes cache root: #{candidate_path}"
79
+ end
80
+
81
+ def lock_path_for(lock_key)
82
+ safe_key = sanitize_key(lock_key)
83
+ File.join(@lock_dir, "#{safe_key}.lock")
84
+ end
85
+
86
+ def sanitize_key(key)
87
+ key.to_s.gsub(/[^0-9A-Za-z._-]/, "_")
88
+ end
89
+
90
+ def staging_suffix(prefix)
91
+ safe = sanitize_key(prefix)
92
+ token = SecureRandom.hex(6)
93
+ "#{safe}.#{Process.pid}.#{Thread.current.object_id}.#{token}"
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,137 @@
1
+ #!/bin/bash
2
+ # scint-linker — bulk-hardlink cached gems into a bundle.
3
+ #
4
+ # Protocol (stdin):
5
+ # Line 1: source parent dir (e.g. ~/.cache/scint/cached/ruby-3.4.7-x86_64-linux)
6
+ # Line 2: dest parent dir (e.g. .bundle/ruby/3.4.0/gems)
7
+ # Remaining lines: gem directory basenames (e.g. "rack-3.2.4")
8
+ #
9
+ # The script probes the best copy strategy once (cpio, hardlink, reflink,
10
+ # copy) and uses it for every gem. When cpio is available it reads
11
+ # .scint-files from each cached gem so only listed files are materialized.
12
+ # Otherwise falls back to cp -al / cp --reflink / cp -R.
13
+
14
+ set -euo pipefail
15
+
16
+ read -r SRC
17
+ read -r DST
18
+ mkdir -p "$DST"
19
+
20
+ # ── detect strategy ──────────────────────────────────────────────
21
+ STRATEGY=""
22
+
23
+ # Probe needs a real file to test against. Find one quickly.
24
+ probe_src=""
25
+ for candidate in "$SRC"/*/.scint-files; do
26
+ [ -f "$candidate" ] && probe_src="$candidate" && break
27
+ done
28
+
29
+ if [ -z "$probe_src" ]; then
30
+ # No .scint-files at all — fall back to cp -al or cp -R
31
+ if cp -al --version >/dev/null 2>&1; then
32
+ STRATEGY="cp-al"
33
+ else
34
+ STRATEGY="cp"
35
+ fi
36
+ else
37
+ probe_dst="$DST/.scint-probe-$$"
38
+
39
+ # 1. cpio -pld (file-list driven hardlinks — ideal)
40
+ if command -v cpio >/dev/null 2>&1; then
41
+ if echo "$probe_src" | cpio -pld "$DST" >/dev/null 2>&1; then
42
+ rm -f "$probe_dst" 2>/dev/null
43
+ STRATEGY="cpio"
44
+ fi
45
+ fi
46
+
47
+ # 2. hardlink via ln
48
+ if [ -z "$STRATEGY" ]; then
49
+ if ln "$probe_src" "$probe_dst" 2>/dev/null; then
50
+ rm -f "$probe_dst"
51
+ STRATEGY="cp-al"
52
+ fi
53
+ fi
54
+
55
+ # 3. reflink (btrfs/xfs/APFS)
56
+ if [ -z "$STRATEGY" ]; then
57
+ if [ "$(uname)" = "Darwin" ]; then
58
+ if cp -c "$probe_src" "$probe_dst" 2>/dev/null; then
59
+ rm -f "$probe_dst"
60
+ STRATEGY="reflink"
61
+ fi
62
+ else
63
+ if cp --reflink=always "$probe_src" "$probe_dst" 2>/dev/null; then
64
+ rm -f "$probe_dst"
65
+ STRATEGY="reflink"
66
+ fi
67
+ fi
68
+ fi
69
+
70
+ rm -f "$probe_dst" 2>/dev/null
71
+ [ -z "$STRATEGY" ] && STRATEGY="cp"
72
+ fi
73
+
74
+ # ── link gems ────────────────────────────────────────────────────
75
+
76
+ link_cpio() {
77
+ # Read .scint-files, prefix each line with gem name, pipe to cpio -pld.
78
+ # One cpio call per gem (cpio needs a single source root).
79
+ local gem="$1"
80
+ local dotfiles="$SRC/$gem/.scint-files"
81
+ if [ -f "$dotfiles" ]; then
82
+ (cd "$SRC/$gem" && cpio -pld "$DST/$gem" < "$dotfiles" 2>/dev/null)
83
+ else
84
+ cp -al "$SRC/$gem" "$DST/$gem"
85
+ fi
86
+ }
87
+
88
+ # For cp-al: batch all gems into one call
89
+ BATCH=()
90
+
91
+ flush_batch() {
92
+ [ ${#BATCH[@]} -eq 0 ] && return
93
+ case "$STRATEGY" in
94
+ cp-al)
95
+ cp -al "${BATCH[@]}" "$DST/" 2>/dev/null || {
96
+ # Individual fallback on batch failure
97
+ for s in "${BATCH[@]}"; do
98
+ cp -al "$s" "$DST/" 2>/dev/null || cp -R "$s" "$DST/"
99
+ done
100
+ }
101
+ ;;
102
+ reflink)
103
+ if [ "$(uname)" = "Darwin" ]; then
104
+ cp -cR "${BATCH[@]}" "$DST/" 2>/dev/null || {
105
+ for s in "${BATCH[@]}"; do cp -R "$s" "$DST/"; done
106
+ }
107
+ else
108
+ cp --reflink=always -R "${BATCH[@]}" "$DST/" 2>/dev/null || {
109
+ for s in "${BATCH[@]}"; do cp -R "$s" "$DST/"; done
110
+ }
111
+ fi
112
+ ;;
113
+ cp)
114
+ cp -R "${BATCH[@]}" "$DST/"
115
+ ;;
116
+ esac
117
+ BATCH=()
118
+ }
119
+
120
+ while IFS= read -r gem; do
121
+ [ -z "$gem" ] && continue
122
+ [ -d "$SRC/$gem" ] || continue
123
+ [ -d "$DST/$gem" ] && continue
124
+
125
+ case "$STRATEGY" in
126
+ cpio)
127
+ link_cpio "$gem"
128
+ ;;
129
+ *)
130
+ BATCH+=("$SRC/$gem")
131
+ # Flush in chunks to stay under ARG_MAX
132
+ [ ${#BATCH[@]} -ge 200 ] && flush_batch
133
+ ;;
134
+ esac
135
+ done
136
+
137
+ flush_batch