middleman-s3_sync 4.6.4 → 4.6.5

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: e6b576cfe91e75975edadd71f0cfd319e84772edecd882aabe2e3c8c06c90082
4
- data.tar.gz: 86bc102e074f9b47980e4cbf93060babd7f7b8a5117cbf35b60ea78ba7476ff0
3
+ metadata.gz: ae6310b6bdcb35bb9bdeb6b35b5a5471321cd2aa31e2301cb2b85bb40f06c6d8
4
+ data.tar.gz: d0a22baf1a2f20e5e2ea4ffa0a727042ad42b8d0234ee058925a83c85557705a
5
5
  SHA512:
6
- metadata.gz: 20b465799e297cff4e8085ba9c9e25637a25a3a77242d862f1d2b93291dd6112c32835fc91eb8fd92d0e4cd11df6676c034330d804e4556b5fc0ba4c2312215e
7
- data.tar.gz: 36a4b96fa0c6831047a90ef4140c598bfab3eb5279fffa09adeee5e23673cb30a811806019ea63de16d1c52383b9a644a2ed6cf0bf400ccd1e9eaade616ae00a
6
+ metadata.gz: 133e5c9a90cd90800cb4c63e443a529b5ea7d0c3d2f803972b58b0ccc01a0af4c0188932f7d0bb3030e42d157c7722f3e8e2cc8c0757c9d5207075fe016653ad
7
+ data.tar.gz: 0d1438c52b583df33a8bce0ac0faab99d13675470092c70facd356508e096c55fdfc4d64e4bb2a58bdc03ba3cda88afcc7b378f3f5f58374939bd678b46f1de2
data/Changelog.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  The gem that tries really hard not to push files to S3.
4
4
 
5
+ ## v4.6.5
6
+ - Performance and stability improvements
7
+ - Thread-safe invalidation path tracking (use Set + mutex) when running in parallel
8
+ - Cache CloudFront client (with reset hook for tests)
9
+ - Single-pass resource categorization (reduce multiple iterations over resources)
10
+ - Batch S3 deletes via delete_objects (up to 1000 keys/request)
11
+ - Stream file uploads to reduce memory; compute MD5s in a single read when possible
12
+ - Optimize CloudFront path deduplication to O(n × path_depth)
13
+ - CLI/extension: support option writers (e.g., verbose=, dry_run=) to fix NoMethodError
14
+ - Tests: add coverage for CloudFront, batch delete, and streaming uploads
15
+ - No breaking changes; default behavior preserved
16
+
5
17
  ## v4.6.4
6
18
  * Remove map gem dependency and replace with native Ruby implementation
7
19
  * Add IndifferentHash class to provide string/symbol indifferent access without external dependencies
data/WARP.md CHANGED
@@ -100,6 +100,10 @@ The gem determines what to do with each file by comparing:
100
100
  - **`config.rb`**: Middleman configuration with `activate :s3_sync` block
101
101
  - **Environment Variables**: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_BUCKET, etc.
102
102
 
103
+ ## Development Rules
104
+
105
+ **All changes must be accompanied with unit tests.** This is non-negotiable for maintaining code quality and preventing regressions.
106
+
103
107
  ## Common Development Patterns
104
108
 
105
109
  When adding new functionality:
@@ -109,4 +113,4 @@ When adding new functionality:
109
113
  4. Add comprehensive specs following existing patterns
110
114
  5. Update README.md with new configuration options
111
115
 
112
- The codebase emphasizes security (credential handling), efficiency (parallel operations), and reliability (comprehensive error handling and dry-run support).
116
+ The codebase emphasizes security (credential handling), efficiency (parallel operations), and reliability (comprehensive error handling and dry-run support).
@@ -1,5 +1,6 @@
1
1
  require 'aws-sdk-cloudfront'
2
2
  require 'securerandom'
3
+ require 'set'
3
4
 
4
5
  module Middleman
5
6
  module S3Sync
@@ -101,25 +102,42 @@ module Middleman
101
102
  # Sort paths to ensure wildcards come before specific files
102
103
  sorted_paths = paths.sort
103
104
  result = []
105
+ # Use a Set for O(1) lookup of wildcard prefixes
106
+ wildcard_prefixes = Set.new
104
107
 
105
108
  sorted_paths.each do |path|
106
- # Check if this path is already covered by a wildcard we've added
107
- is_redundant = result.any? do |existing_path|
108
- if existing_path.end_with?('/*')
109
- # Check if current path is under this wildcard
110
- wildcard_prefix = existing_path[0..-3] # Remove /*
111
- path.start_with?(wildcard_prefix + '/')
112
- else
113
- false
109
+ # Check if this path is covered by any existing wildcard prefix
110
+ # by checking all parent directories of this path
111
+ is_redundant = path_covered_by_wildcard?(path, wildcard_prefixes)
112
+
113
+ unless is_redundant
114
+ result << path
115
+ # If this is a wildcard path, add its prefix for future lookups
116
+ if path.end_with?('/*')
117
+ wildcard_prefixes.add(path[0..-3]) # Remove /*
114
118
  end
115
119
  end
116
-
117
- result << path unless is_redundant
118
120
  end
119
121
 
120
122
  result
121
123
  end
122
124
 
125
+ # Check if a path is covered by any wildcard prefix in O(path_depth) time
126
+ def path_covered_by_wildcard?(path, wildcard_prefixes)
127
+ return false if wildcard_prefixes.empty?
128
+
129
+ # Check each parent directory of the path
130
+ segments = path.split('/')
131
+ current_path = ''
132
+
133
+ segments[0..-2].each do |segment| # Exclude the last segment
134
+ current_path = current_path.empty? ? segment : "#{current_path}/#{segment}"
135
+ return true if wildcard_prefixes.include?(current_path)
136
+ end
137
+
138
+ false
139
+ end
140
+
123
141
  def create_invalidation_with_retry(paths, options)
124
142
  max_retries = options.cloudfront_invalidation_max_retries || 5
125
143
  retries = 0
@@ -161,24 +179,30 @@ module Middleman
161
179
  end
162
180
 
163
181
  def cloudfront_client(options)
164
- client_options = {
165
- region: 'us-east-1' # CloudFront is always in us-east-1
166
- }
167
-
168
- # Use the same credentials as S3 if available
169
- if options.aws_access_key_id && options.aws_secret_access_key
170
- client_options.merge!({
171
- access_key_id: options.aws_access_key_id,
172
- secret_access_key: options.aws_secret_access_key
173
- })
174
-
175
- # If using an assumed role
176
- client_options.merge!({
177
- session_token: options.aws_session_token
178
- }) if options.aws_session_token
182
+ @cloudfront_client ||= begin
183
+ client_options = {
184
+ region: 'us-east-1' # CloudFront is always in us-east-1
185
+ }
186
+
187
+ # Use the same credentials as S3 if available
188
+ if options.aws_access_key_id && options.aws_secret_access_key
189
+ client_options.merge!({
190
+ access_key_id: options.aws_access_key_id,
191
+ secret_access_key: options.aws_secret_access_key
192
+ })
193
+
194
+ # If using an assumed role
195
+ client_options.merge!({
196
+ session_token: options.aws_session_token
197
+ }) if options.aws_session_token
198
+ end
199
+
200
+ Aws::CloudFront::Client.new(client_options)
179
201
  end
202
+ end
180
203
 
181
- Aws::CloudFront::Client.new(client_options)
204
+ def reset_cloudfront_client!
205
+ @cloudfront_client = nil
182
206
  end
183
207
 
184
208
  def wait_for_invalidations(invalidation_ids, options)
@@ -116,20 +116,25 @@ module Middleman
116
116
 
117
117
  def upload!
118
118
  object = bucket.object(remote_path.sub(/^\//, ''))
119
- upload_options = build_upload_options
120
-
121
- begin
122
- object.put(upload_options)
123
- rescue Aws::S3::Errors::AccessControlListNotSupported => e
124
- # Bucket has ACLs disabled - retry without ACL
125
- if upload_options.key?(:acl)
126
- say_status "#{ANSI.yellow{"Note"}} Bucket does not support ACLs, retrying without ACL parameter"
127
- # Automatically disable ACLs for this bucket going forward
128
- options.acl = ''
129
- upload_options.delete(:acl)
130
- retry
131
- else
132
- raise e
119
+
120
+ # Use streaming upload for memory efficiency with large files
121
+ File.open(local_path, 'rb') do |file|
122
+ upload_options = build_upload_options_for_stream(file)
123
+
124
+ begin
125
+ object.put(upload_options)
126
+ rescue Aws::S3::Errors::AccessControlListNotSupported => e
127
+ # Bucket has ACLs disabled - retry without ACL
128
+ if upload_options.key?(:acl)
129
+ say_status "#{ANSI.yellow{"Note"}} Bucket does not support ACLs, retrying without ACL parameter"
130
+ # Automatically disable ACLs for this bucket going forward
131
+ options.acl = ''
132
+ upload_options.delete(:acl)
133
+ file.rewind # Reset file position for retry
134
+ retry
135
+ else
136
+ raise e
137
+ end
133
138
  end
134
139
  end
135
140
  end
@@ -278,12 +283,24 @@ module Middleman
278
283
  end
279
284
 
280
285
  def local_object_md5
281
- @local_object_md5 ||= Digest::MD5.hexdigest(File.read(local_path))
286
+ @local_object_md5 ||= begin
287
+ # When not gzipped, compute both MD5s in single read to avoid redundant I/O
288
+ if !gzipped && local_path == original_path
289
+ compute_md5s_single_read
290
+ @local_object_md5
291
+ else
292
+ Digest::MD5.hexdigest(File.read(local_path))
293
+ end
294
+ end
282
295
  end
283
296
 
284
297
  def local_content_md5
285
298
  @local_content_md5 ||= begin
286
- if File.exist?(original_path)
299
+ # When not gzipped, compute both MD5s in single read to avoid redundant I/O
300
+ if !gzipped && local_path == original_path
301
+ compute_md5s_single_read
302
+ @local_content_md5
303
+ elsif File.exist?(original_path)
287
304
  Digest::MD5.hexdigest(File.read(original_path))
288
305
  else
289
306
  nil
@@ -291,6 +308,16 @@ module Middleman
291
308
  end
292
309
  end
293
310
 
311
+ # Compute both MD5s from a single file read when they're the same file
312
+ def compute_md5s_single_read
313
+ return if @md5s_computed
314
+ content = File.read(local_path)
315
+ md5 = Digest::MD5.hexdigest(content)
316
+ @local_object_md5 = md5
317
+ @local_content_md5 = md5
318
+ @md5s_computed = true
319
+ end
320
+
294
321
  def original_path
295
322
  gzipped ? local_path.gsub(/\.gz$/, '') : local_path
296
323
  end
@@ -314,9 +341,10 @@ module Middleman
314
341
 
315
342
  protected
316
343
 
317
- def build_upload_options
344
+ # Build upload options with a file stream as the body
345
+ def build_upload_options_for_stream(file_stream)
318
346
  upload_options = {
319
- body: local_content,
347
+ body: file_stream,
320
348
  content_type: content_type
321
349
  }
322
350
  # Only add ACL if enabled (not for buckets with ACLs disabled)
@@ -1,5 +1,5 @@
1
1
  module Middleman
2
2
  module S3Sync
3
- VERSION = "4.6.4"
3
+ VERSION = "4.6.5"
4
4
  end
5
5
  end
@@ -11,6 +11,7 @@ require 'middleman/redirect'
11
11
  require 'parallel'
12
12
  require 'ruby-progressbar'
13
13
  require 'thread'
14
+ require 'set'
14
15
 
15
16
  module Middleman
16
17
  module S3Sync
@@ -20,6 +21,7 @@ module Middleman
20
21
 
21
22
  @@bucket_lock = Mutex.new
22
23
  @@bucket_files_lock = Mutex.new
24
+ @@invalidation_paths_lock = Mutex.new
23
25
 
24
26
  attr_accessor :s3_sync_options
25
27
  attr_accessor :mm_resources
@@ -28,11 +30,12 @@ module Middleman
28
30
  THREADS_COUNT = 8
29
31
 
30
32
  # Track paths that were changed during sync for CloudFront invalidation
31
- attr_accessor :invalidation_paths
33
+ # Using a Set for O(1) lookups
34
+ attr_reader :invalidation_paths
32
35
 
33
36
  def sync()
34
37
  @app ||= ::Middleman::Application.new
35
- @invalidation_paths = []
38
+ @invalidation_paths = Set.new
36
39
 
37
40
  say_status "Let's see if there's work to be done..."
38
41
  unless work_to_be_done?
@@ -58,7 +61,7 @@ module Middleman
58
61
 
59
62
  # Invalidate CloudFront cache if requested
60
63
  if s3_sync_options.cloudfront_invalidate
61
- CloudFront.invalidate(@invalidation_paths, s3_sync_options)
64
+ CloudFront.invalidate(@invalidation_paths.to_a, s3_sync_options)
62
65
  end
63
66
  end
64
67
 
@@ -77,10 +80,12 @@ module Middleman
77
80
  end
78
81
 
79
82
  def add_invalidation_path(path)
80
- @invalidation_paths ||= []
81
- # Normalize path for CloudFront (ensure it starts with /)
82
- normalized_path = path.start_with?('/') ? path : "/#{path}"
83
- @invalidation_paths << normalized_path unless @invalidation_paths.include?(normalized_path)
83
+ @@invalidation_paths_lock.synchronize do
84
+ @invalidation_paths ||= Set.new
85
+ # Normalize path for CloudFront (ensure it starts with /)
86
+ normalized_path = path.start_with?('/') ? path : "/#{path}"
87
+ @invalidation_paths.add(normalized_path)
88
+ end
84
89
  end
85
90
 
86
91
  def remote_only_paths
@@ -204,10 +209,22 @@ module Middleman
204
209
  end
205
210
 
206
211
  def delete_resources
207
- Parallel.map(files_to_delete, in_threads: THREADS_COUNT) do |resource|
208
- resource.destroy!
212
+ resources = files_to_delete
213
+ return if resources.empty?
214
+
215
+ # Print status messages for all resources being deleted
216
+ resources.each do |resource|
217
+ say_status "#{ANSI.red{"Deleting"}} #{resource.remote_path}"
209
218
  add_invalidation_path(resource.path)
210
219
  end
220
+
221
+ # Batch delete using S3's delete_objects API (up to 1000 objects per request)
222
+ unless s3_sync_options.dry_run
223
+ resources.each_slice(1000) do |batch|
224
+ objects_to_delete = batch.map { |r| { key: r.remote_path.sub(/^\//, '') } }
225
+ bucket.delete_objects(delete: { objects: objects_to_delete })
226
+ end
227
+ end
211
228
  end
212
229
 
213
230
  def ignore_resources
@@ -224,24 +241,41 @@ module Middleman
224
241
  !(files_to_create.empty? && files_to_update.empty? && files_to_delete.empty?)
225
242
  end
226
243
 
244
+ # Single-pass categorization of resources by status
245
+ # Avoids multiple iterations over s3_sync_resources
246
+ def categorized_resources
247
+ @categorized_resources ||= begin
248
+ result = { create: [], update: [], delete: [], ignore: [] }
249
+ s3_sync_resources.values.each do |resource|
250
+ case
251
+ when resource.to_create? then result[:create] << resource
252
+ when resource.to_update? then result[:update] << resource
253
+ when resource.to_delete? then result[:delete] << resource
254
+ when resource.to_ignore? then result[:ignore] << resource
255
+ end
256
+ end
257
+ result
258
+ end
259
+ end
260
+
227
261
  def files_to_delete
228
262
  if s3_sync_options.delete
229
- s3_sync_resources.values.select { |r| r.to_delete? }
263
+ categorized_resources[:delete]
230
264
  else
231
265
  []
232
266
  end
233
267
  end
234
268
 
235
269
  def files_to_create
236
- s3_sync_resources.values.select { |r| r.to_create? }
270
+ categorized_resources[:create]
237
271
  end
238
272
 
239
273
  def files_to_update
240
- s3_sync_resources.values.select { |r| r.to_update? }
274
+ categorized_resources[:update]
241
275
  end
242
276
 
243
277
  def files_to_ignore
244
- s3_sync_resources.values.select { |r| r.to_ignore? }
278
+ categorized_resources[:ignore]
245
279
  end
246
280
 
247
281
  def build_dir
@@ -87,16 +87,30 @@ module Middleman
87
87
  true
88
88
  end
89
89
 
90
- # Delegate option readers to the options object
90
+ # Delegate option readers and writers to the options object
91
91
  def method_missing(method, *args, &block)
92
- if options.respond_to?(method)
93
- options.send(method, *args, &block)
94
- else
95
- super
92
+ method_str = method.to_s
93
+
94
+ # Handle setter methods (e.g., verbose=)
95
+ if method_str.end_with?('=')
96
+ option_name = method_str.chomp('=').to_sym
97
+ if options.respond_to?(option_name)
98
+ options[option_name] = args.first
99
+ return args.first
100
+ end
101
+ elsif options.respond_to?(method)
102
+ return options.send(method, *args, &block)
96
103
  end
104
+
105
+ super
97
106
  end
98
107
 
99
108
  def respond_to_missing?(method, include_private = false)
109
+ method_str = method.to_s
110
+ if method_str.end_with?('=')
111
+ option_name = method_str.chomp('=').to_sym
112
+ return options.respond_to?(option_name)
113
+ end
100
114
  options.respond_to?(method) || super
101
115
  end
102
116
 
@@ -128,14 +128,17 @@ describe 'AWS SDK Parameter Validation' do
128
128
  allow(File).to receive(:exist?).with('build/test/file.html.gz').and_return(false)
129
129
  allow(File).to receive(:read).with('build/test/file.html').and_return('test content')
130
130
  allow(File).to receive(:directory?).with('build/test/file.html').and_return(false)
131
+ # Stub File.open to return a StringIO for streaming upload tests
132
+ allow(File).to receive(:open).with('build/test/file.html', 'rb').and_yield(StringIO.new('test content'))
131
133
  allow(s3_object).to receive(:head).and_return(nil)
132
134
  options.dry_run = false
133
135
  end
134
136
 
135
137
  it 'uses correct metadata key format' do
136
138
  expect(s3_object).to receive(:put) do |upload_options|
137
- # Verify basic parameters
138
- expect(upload_options[:body]).to eq('test content')
139
+ # Verify body is a readable IO object (for streaming)
140
+ expect(upload_options[:body]).to respond_to(:read)
141
+ expect(upload_options[:body].read).to eq('test content')
139
142
  expect(upload_options[:content_type]).to eq('text/html')
140
143
  expect(upload_options[:acl]).to eq('public-read')
141
144
 
@@ -160,7 +163,7 @@ describe 'AWS SDK Parameter Validation' do
160
163
  it 'does not include acl parameter in upload' do
161
164
  expect(s3_object).to receive(:put) do |upload_options|
162
165
  expect(upload_options).not_to have_key(:acl)
163
- expect(upload_options[:body]).to eq('test content')
166
+ expect(upload_options[:body]).to respond_to(:read)
164
167
  expect(upload_options[:content_type]).to eq('text/html')
165
168
  end
166
169
 
@@ -176,7 +179,7 @@ describe 'AWS SDK Parameter Validation' do
176
179
  it 'does not include acl parameter in upload' do
177
180
  expect(s3_object).to receive(:put) do |upload_options|
178
181
  expect(upload_options).not_to have_key(:acl)
179
- expect(upload_options[:body]).to eq('test content')
182
+ expect(upload_options[:body]).to respond_to(:read)
180
183
  expect(upload_options[:content_type]).to eq('text/html')
181
184
  end
182
185
 
@@ -192,6 +195,10 @@ describe 'AWS SDK Parameter Validation' do
192
195
 
193
196
  it 'automatically retries without ACL when AccessControlListNotSupported error occurs' do
194
197
  call_count = 0
198
+ # Use a reusable StringIO that can be rewound
199
+ file_io = StringIO.new('test content')
200
+ allow(File).to receive(:open).with('build/test/file.html', 'rb').and_yield(file_io)
201
+
195
202
  expect(s3_object).to receive(:put).twice do |upload_options|
196
203
  call_count += 1
197
204
  if call_count == 1
@@ -201,7 +208,7 @@ describe 'AWS SDK Parameter Validation' do
201
208
  else
202
209
  # Second call should not include ACL
203
210
  expect(upload_options).not_to have_key(:acl)
204
- expect(upload_options[:body]).to eq('test content')
211
+ expect(upload_options[:body]).to respond_to(:read)
205
212
  expect(upload_options[:content_type]).to eq('text/html')
206
213
  end
207
214
  end
@@ -219,7 +226,7 @@ describe 'AWS SDK Parameter Validation' do
219
226
  expect(upload_options[:acl]).to eq('public-read')
220
227
  raise Aws::S3::Errors::AccessControlListNotSupported.new(nil, 'The bucket does not allow ACLs')
221
228
  else
222
- # Second call should succeed without ACL
229
+ # Subsequent calls should succeed without ACL
223
230
  expect(upload_options).not_to have_key(:acl)
224
231
  true
225
232
  end
@@ -241,6 +248,7 @@ describe 'AWS SDK Parameter Validation' do
241
248
  allow(File).to receive(:read).with('build/test/file.html.gz').and_return('gzipped content')
242
249
  allow(File).to receive(:exist?).with('build/test/file.html').and_return(true)
243
250
  allow(File).to receive(:read).with('build/test/file.html').and_return('original content')
251
+ allow(File).to receive(:open).with('build/test/file.html.gz', 'rb').and_yield(StringIO.new('gzipped content'))
244
252
 
245
253
  # Mock the HEAD response to avoid calling it during redirect?
246
254
  head_response = double(
@@ -305,6 +313,7 @@ describe 'AWS SDK Parameter Validation' do
305
313
  allow(File).to receive(:exist?).with('build/redirect/file.html').and_return(true)
306
314
  allow(File).to receive(:exist?).with('build/redirect/file.html.gz').and_return(false)
307
315
  allow(File).to receive(:read).with('build/redirect/file.html').and_return('redirect content')
316
+ allow(File).to receive(:open).with('build/redirect/file.html', 'rb').and_yield(StringIO.new('redirect content'))
308
317
  allow(File).to receive(:directory?).with('build/redirect/file.html').and_return(false)
309
318
  allow(s3_object).to receive(:head).and_return(nil)
310
319
  allow(resource).to receive(:redirect?).and_return(true)
@@ -478,6 +487,7 @@ describe 'AWS SDK Parameter Validation' do
478
487
  allow(File).to receive(:exist?).with('build/test/file.html').and_return(true)
479
488
  allow(File).to receive(:exist?).with('build/test/file.html.gz').and_return(false)
480
489
  allow(File).to receive(:read).with('build/test/file.html').and_return('test content')
490
+ allow(File).to receive(:open).with('build/test/file.html', 'rb').and_yield(StringIO.new('test content'))
481
491
  allow(File).to receive(:directory?).with('build/test/file.html').and_return(false)
482
492
  allow(s3_object).to receive(:head).and_return(nil)
483
493
  options.dry_run = false
@@ -27,6 +27,8 @@ describe Middleman::S3Sync::CloudFront do
27
27
 
28
28
  before do
29
29
  allow(described_class).to receive(:say_status)
30
+ # Reset cached CloudFront client between tests to prevent double leakage
31
+ described_class.send(:reset_cloudfront_client!)
30
32
  end
31
33
 
32
34
  describe '.invalidate' do
@@ -0,0 +1,278 @@
1
+ require 'spec_helper'
2
+
3
+ describe Middleman::S3Sync::IndifferentHash do
4
+ let(:hash) { described_class.new }
5
+
6
+ describe 'string-indifferent key access' do
7
+ it 'allows setting and retrieving values using string keys' do
8
+ hash['foo'] = 'bar'
9
+ expect(hash['foo']).to eq('bar')
10
+ end
11
+
12
+ it 'retrieves string key values using symbol keys' do
13
+ hash['foo'] = 'bar'
14
+ expect(hash[:foo]).to eq('bar')
15
+ end
16
+
17
+ it 'normalizes string keys on retrieval' do
18
+ hash['foo'] = 'value1'
19
+ hash['foo'] = 'value2'
20
+ expect(hash['foo']).to eq('value2')
21
+ expect(hash.keys.count).to eq(1)
22
+ end
23
+ end
24
+
25
+ describe 'symbol-indifferent key access' do
26
+ it 'allows setting and retrieving values using symbol keys' do
27
+ hash[:foo] = 'bar'
28
+ expect(hash[:foo]).to eq('bar')
29
+ end
30
+
31
+ it 'retrieves symbol key values using string keys' do
32
+ hash[:foo] = 'bar'
33
+ expect(hash['foo']).to eq('bar')
34
+ end
35
+
36
+ it 'normalizes symbol keys to strings' do
37
+ hash[:foo] = 'value1'
38
+ hash['foo'] = 'value2'
39
+ expect(hash[:foo]).to eq('value2')
40
+ expect(hash.keys.count).to eq(1)
41
+ end
42
+
43
+ it 'stores keys as strings internally' do
44
+ hash[:foo] = 'bar'
45
+ expect(hash.keys).to eq(['foo'])
46
+ end
47
+ end
48
+
49
+ describe 'dot notation access' do
50
+ it 'allows accessing values using dot notation' do
51
+ hash[:foo] = 'bar'
52
+ expect(hash.foo).to eq('bar')
53
+ end
54
+
55
+ it 'allows setting values using dot notation' do
56
+ hash.foo = 'baz'
57
+ expect(hash[:foo]).to eq('baz')
58
+ end
59
+
60
+ it 'works with string keys' do
61
+ hash['foo'] = 'bar'
62
+ expect(hash.foo).to eq('bar')
63
+ end
64
+
65
+ it 'raises NoMethodError for non-existent keys' do
66
+ expect { hash.nonexistent_key }.to raise_error(NoMethodError)
67
+ end
68
+
69
+ it 'supports respond_to? for existing keys' do
70
+ hash[:foo] = 'bar'
71
+ expect(hash).to respond_to(:foo)
72
+ expect(hash).to respond_to(:foo=)
73
+ end
74
+
75
+ it 'returns false for respond_to? on non-existent keys' do
76
+ expect(hash).not_to respond_to(:nonexistent)
77
+ end
78
+ end
79
+
80
+ describe 'nested hashes with indifferent access' do
81
+ it 'handles nested hash values' do
82
+ hash[:outer] = { inner: 'value' }
83
+ expect(hash[:outer]).to eq({ inner: 'value' })
84
+ end
85
+
86
+ it 'allows nested IndifferentHash instances' do
87
+ inner = described_class.new
88
+ inner[:foo] = 'bar'
89
+ hash[:outer] = inner
90
+
91
+ expect(hash[:outer][:foo]).to eq('bar')
92
+ expect(hash[:outer].foo).to eq('bar')
93
+ end
94
+
95
+ it 'supports multiple levels of nesting' do
96
+ inner = described_class.new
97
+ inner[:level2] = 'deep'
98
+ hash[:level1] = inner
99
+
100
+ expect(hash['level1']['level2']).to eq('deep')
101
+ expect(hash[:level1][:level2]).to eq('deep')
102
+ end
103
+ end
104
+
105
+ describe 'Hash method compatibility' do
106
+ describe '#has_key?' do
107
+ it 'works with string keys' do
108
+ hash['foo'] = 'bar'
109
+ expect(hash.has_key?('foo')).to be true
110
+ expect(hash.has_key?(:foo)).to be true
111
+ end
112
+
113
+ it 'works with symbol keys' do
114
+ hash[:foo] = 'bar'
115
+ expect(hash.has_key?('foo')).to be true
116
+ expect(hash.has_key?(:foo)).to be true
117
+ end
118
+
119
+ it 'returns false for non-existent keys' do
120
+ expect(hash.has_key?('nonexistent')).to be false
121
+ expect(hash.has_key?(:nonexistent)).to be false
122
+ end
123
+ end
124
+
125
+ describe '#key?' do
126
+ it 'is aliased to has_key?' do
127
+ hash[:foo] = 'bar'
128
+ expect(hash.key?('foo')).to be true
129
+ expect(hash.key?(:foo)).to be true
130
+ end
131
+ end
132
+
133
+ describe '#include?' do
134
+ it 'is aliased to has_key?' do
135
+ hash[:foo] = 'bar'
136
+ expect(hash.include?('foo')).to be true
137
+ expect(hash.include?(:foo)).to be true
138
+ end
139
+ end
140
+
141
+ describe '#fetch' do
142
+ it 'fetches values with string keys' do
143
+ hash['foo'] = 'bar'
144
+ expect(hash.fetch('foo')).to eq('bar')
145
+ expect(hash.fetch(:foo)).to eq('bar')
146
+ end
147
+
148
+ it 'fetches values with symbol keys' do
149
+ hash[:foo] = 'bar'
150
+ expect(hash.fetch('foo')).to eq('bar')
151
+ expect(hash.fetch(:foo)).to eq('bar')
152
+ end
153
+
154
+ it 'returns default value for missing keys' do
155
+ expect(hash.fetch('missing', 'default')).to eq('default')
156
+ expect(hash.fetch(:missing, 'default')).to eq('default')
157
+ end
158
+
159
+ it 'calls block for missing keys' do
160
+ result = hash.fetch('missing') { |key| "Key #{key} not found" }
161
+ expect(result).to eq('Key missing not found')
162
+ end
163
+
164
+ it 'raises KeyError when key is missing without default' do
165
+ expect { hash.fetch('missing') }.to raise_error(KeyError)
166
+ expect { hash.fetch(:missing) }.to raise_error(KeyError)
167
+ end
168
+ end
169
+ end
170
+
171
+ describe '.from_hash' do
172
+ it 'creates an IndifferentHash from a regular hash' do
173
+ regular_hash = { 'foo' => 'bar', 'baz' => 'qux' }
174
+ result = described_class.from_hash(regular_hash)
175
+
176
+ expect(result).to be_a(described_class)
177
+ expect(result['foo']).to eq('bar')
178
+ expect(result[:foo]).to eq('bar')
179
+ expect(result['baz']).to eq('qux')
180
+ expect(result[:baz]).to eq('qux')
181
+ end
182
+
183
+ it 'handles hashes with symbol keys' do
184
+ regular_hash = { foo: 'bar', baz: 'qux' }
185
+ result = described_class.from_hash(regular_hash)
186
+
187
+ expect(result[:foo]).to eq('bar')
188
+ expect(result['foo']).to eq('bar')
189
+ end
190
+
191
+ it 'handles empty hashes' do
192
+ result = described_class.from_hash({})
193
+ expect(result).to be_a(described_class)
194
+ expect(result).to be_empty
195
+ end
196
+
197
+ it 'handles mixed string and symbol keys' do
198
+ regular_hash = { 'string_key' => 'value1', :symbol_key => 'value2' }
199
+ result = described_class.from_hash(regular_hash)
200
+
201
+ expect(result['string_key']).to eq('value1')
202
+ expect(result[:string_key]).to eq('value1')
203
+ expect(result['symbol_key']).to eq('value2')
204
+ expect(result[:symbol_key]).to eq('value2')
205
+ end
206
+ end
207
+
208
+ describe 'map gem API compatibility' do
209
+ # These tests ensure no breaking changes compared to the map gem
210
+
211
+ it 'supports basic key-value storage' do
212
+ hash[:key] = 'value'
213
+ expect(hash[:key]).to eq('value')
214
+ expect(hash['key']).to eq('value')
215
+ end
216
+
217
+ it 'supports dot notation like map gem' do
218
+ hash.max_age = 3600
219
+ expect(hash.max_age).to eq(3600)
220
+ expect(hash[:max_age]).to eq(3600)
221
+ end
222
+
223
+ it 'supports fetch with default values' do
224
+ expect(hash.fetch(:missing, 'default')).to eq('default')
225
+ end
226
+
227
+ it 'supports has_key? checks' do
228
+ hash[:present] = 'value'
229
+ expect(hash.has_key?(:present)).to be true
230
+ expect(hash.has_key?('present')).to be true
231
+ expect(hash.has_key?(:absent)).to be false
232
+ end
233
+
234
+ it 'maintains Hash inheritance' do
235
+ expect(hash).to be_a(Hash)
236
+ end
237
+
238
+ it 'supports standard Hash operations' do
239
+ hash[:a] = 1
240
+ hash[:b] = 2
241
+
242
+ expect(hash.keys.sort).to eq(['a', 'b'])
243
+ expect(hash.values.sort).to eq([1, 2])
244
+ expect(hash.size).to eq(2)
245
+ end
246
+
247
+ it 'supports iteration' do
248
+ hash[:a] = 1
249
+ hash[:b] = 2
250
+
251
+ result = []
252
+ hash.each { |k, v| result << [k, v] }
253
+ expect(result).to contain_exactly(['a', 1], ['b', 2])
254
+ end
255
+
256
+ context 'usage in BrowserCachePolicy' do
257
+ it 'supports caching policy access patterns' do
258
+ # Mimics how BrowserCachePolicy uses the hash
259
+ hash[:max_age] = 3600
260
+ hash[:s_maxage] = 7200
261
+ hash[:public] = true
262
+ hash[:no_cache] = false
263
+
264
+ expect(hash.has_key?(:max_age)).to be true
265
+ expect(hash.has_key?('s_maxage')).to be true
266
+ expect(hash.fetch(:public, false)).to be true
267
+ expect(hash.fetch(:private, false)).to be false
268
+ expect(hash.fetch('must_revalidate', false)).to be false
269
+ end
270
+
271
+ it 'supports mixed string and symbol key access like in caching_policy.rb' do
272
+ hash[:max_age] = 3600
273
+ expect(hash['max_age']).to eq(3600)
274
+ expect(hash.max_age).to eq(3600)
275
+ end
276
+ end
277
+ end
278
+ end
@@ -36,7 +36,7 @@ describe 'S3Sync CloudFront Integration' do
36
36
  describe 'CloudFront invalidation integration' do
37
37
  it 'calls CloudFront invalidation after sync operations' do
38
38
  # Reset invalidation paths for this test
39
- Middleman::S3Sync.instance_variable_set(:@invalidation_paths, [])
39
+ Middleman::S3Sync.instance_variable_set(:@invalidation_paths, Set.new)
40
40
 
41
41
  expect(Middleman::S3Sync::CloudFront).to receive(:invalidate).with(
42
42
  [], # Initially empty, gets populated during resource operations
@@ -104,7 +104,7 @@ describe 'S3Sync CloudFront Integration' do
104
104
  describe 'path tracking during resource operations' do
105
105
  before do
106
106
  # Reset invalidation paths before each path tracking test
107
- Middleman::S3Sync.instance_variable_set(:@invalidation_paths, [])
107
+ Middleman::S3Sync.instance_variable_set(:@invalidation_paths, Set.new)
108
108
  end
109
109
 
110
110
  it 'adds paths to invalidation list when resources are processed' do
@@ -126,7 +126,73 @@ describe 'S3Sync CloudFront Integration' do
126
126
  Middleman::S3Sync.add_invalidation_path('/same/path.html')
127
127
  Middleman::S3Sync.add_invalidation_path('/same/path.html')
128
128
 
129
- expect(Middleman::S3Sync.invalidation_paths.count('/same/path.html')).to eq(1)
129
+ # Set automatically handles uniqueness - verify it contains exactly one occurrence
130
+ expect(Middleman::S3Sync.invalidation_paths.to_a.count('/same/path.html')).to eq(1)
130
131
  end
131
132
  end
132
- end
133
+
134
+ describe 'batch delete operations' do
135
+ let(:bucket) { double('bucket') }
136
+ let(:resource1) { double('resource1', path: 'file1.html', remote_path: 'file1.html') }
137
+ let(:resource2) { double('resource2', path: 'file2.html', remote_path: 'file2.html') }
138
+ let(:resource3) { double('resource3', path: 'file3.html', remote_path: 'file3.html') }
139
+
140
+ before do
141
+ # Remove the stub for delete_resources so we test the actual implementation
142
+ allow(Middleman::S3Sync).to receive(:delete_resources).and_call_original
143
+ allow(Middleman::S3Sync).to receive(:say_status)
144
+ allow(Middleman::S3Sync).to receive(:bucket).and_return(bucket)
145
+ allow(Middleman::S3Sync).to receive(:s3_sync_options).and_return(s3_sync_options)
146
+ Middleman::S3Sync.instance_variable_set(:@invalidation_paths, Set.new)
147
+ Middleman::S3Sync.instance_variable_set(:@categorized_resources, nil)
148
+ end
149
+
150
+ it 'uses batch delete_objects API instead of individual deletes' do
151
+ allow(Middleman::S3Sync).to receive(:files_to_delete).and_return([resource1, resource2, resource3])
152
+
153
+ expect(bucket).to receive(:delete_objects).with(
154
+ delete: {
155
+ objects: [
156
+ { key: 'file1.html' },
157
+ { key: 'file2.html' },
158
+ { key: 'file3.html' }
159
+ ]
160
+ }
161
+ )
162
+
163
+ Middleman::S3Sync.send(:delete_resources)
164
+ end
165
+
166
+ it 'adds invalidation paths for all deleted resources' do
167
+ allow(Middleman::S3Sync).to receive(:files_to_delete).and_return([resource1, resource2])
168
+ allow(bucket).to receive(:delete_objects)
169
+
170
+ Middleman::S3Sync.send(:delete_resources)
171
+
172
+ expect(Middleman::S3Sync.invalidation_paths).to include('/file1.html')
173
+ expect(Middleman::S3Sync.invalidation_paths).to include('/file2.html')
174
+ end
175
+
176
+ it 'does not call delete_objects during dry run' do
177
+ dry_run_options = double(
178
+ dry_run: true,
179
+ delete: true,
180
+ bucket: 'test-bucket'
181
+ )
182
+ allow(Middleman::S3Sync).to receive(:s3_sync_options).and_return(dry_run_options)
183
+ allow(Middleman::S3Sync).to receive(:files_to_delete).and_return([resource1])
184
+
185
+ expect(bucket).not_to receive(:delete_objects)
186
+
187
+ Middleman::S3Sync.send(:delete_resources)
188
+ end
189
+
190
+ it 'does nothing when there are no files to delete' do
191
+ allow(Middleman::S3Sync).to receive(:files_to_delete).and_return([])
192
+
193
+ expect(bucket).not_to receive(:delete_objects)
194
+
195
+ Middleman::S3Sync.send(:delete_resources)
196
+ end
197
+ end
198
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: middleman-s3_sync
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.6.4
4
+ version: 4.6.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Frederic Jean
8
8
  - Will Koehler
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-01-03 00:00:00.000000000 Z
11
+ date: 2026-02-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: middleman-core
@@ -299,6 +299,7 @@ files:
299
299
  - spec/caching_policy_spec.rb
300
300
  - spec/cloudfront_spec.rb
301
301
  - spec/extension_spec.rb
302
+ - spec/indifferent_hash_spec.rb
302
303
  - spec/options_spec.rb
303
304
  - spec/resource_spec.rb
304
305
  - spec/s3_sync_integration_spec.rb
@@ -329,6 +330,7 @@ test_files:
329
330
  - spec/caching_policy_spec.rb
330
331
  - spec/cloudfront_spec.rb
331
332
  - spec/extension_spec.rb
333
+ - spec/indifferent_hash_spec.rb
332
334
  - spec/options_spec.rb
333
335
  - spec/resource_spec.rb
334
336
  - spec/s3_sync_integration_spec.rb