appydave-tools 0.68.0 → 0.70.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.
@@ -11,7 +11,7 @@ module Appydave
11
11
  class ProjectListing
12
12
  # List all brands with summary table
13
13
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
14
- def self.list_brands_with_counts(detailed: false)
14
+ def self.list_brands_with_counts(detailed: false, s3: false)
15
15
  brands = Config.available_brands
16
16
 
17
17
  if brands.empty?
@@ -19,53 +19,113 @@ module Appydave
19
19
  return
20
20
  end
21
21
 
22
- # Gather brand data
23
- brand_data = brands.map { |brand| collect_brand_data(brand, detailed: detailed) }
22
+ # Gather brand data (skip S3 if not requested)
23
+ brand_data = brands.map { |brand| collect_brand_data(brand, detailed: detailed, s3: s3) }
24
24
 
25
25
  if detailed
26
26
  # Detailed view with additional columns
27
- header = 'BRAND KEY PROJECTS SIZE LAST MODIFIED ' \
28
- 'GIT S3 SYNC PATH SSD BACKUP ' \
29
- 'WORKFLOW ACTIVE'
30
- puts header
31
- puts '-' * 200
27
+ if s3
28
+ header = 'BRAND KEY PROJECTS SIZE LAST MODIFIED ' \
29
+ 'GIT S3 SYNC PATH SSD BACKUP ' \
30
+ 'WORKFLOW ACTIVE'
31
+ puts header
32
+ puts '-' * 200
33
+ else
34
+ header = 'BRAND KEY PROJECTS SIZE LAST MODIFIED ' \
35
+ 'GIT PATH SSD BACKUP ' \
36
+ 'WORKFLOW ACTIVE'
37
+ puts header
38
+ puts '-' * 189
39
+ end
32
40
 
33
41
  brand_data.each do |data|
34
42
  brand_display = "#{data[:shortcut]} - #{data[:name]}"
35
43
 
44
+ if s3
45
+ puts format(
46
+ '%-30s %-12s %10d %12s %20s %-15s %-10s %-35s %-30s %-10s %6d',
47
+ brand_display,
48
+ data[:key],
49
+ data[:count],
50
+ format_size(data[:size]),
51
+ format_date(data[:modified]),
52
+ data[:git_status],
53
+ data[:s3_sync],
54
+ shorten_path(data[:path]),
55
+ data[:ssd_backup] || 'N/A',
56
+ data[:workflow] || 'N/A',
57
+ data[:active_count] || 0
58
+ )
59
+ else
60
+ puts format(
61
+ '%-30s %-12s %10d %12s %20s %-15s %-35s %-30s %-10s %6d',
62
+ brand_display,
63
+ data[:key],
64
+ data[:count],
65
+ format_size(data[:size]),
66
+ format_date(data[:modified]),
67
+ data[:git_status],
68
+ shorten_path(data[:path]),
69
+ data[:ssd_backup] || 'N/A',
70
+ data[:workflow] || 'N/A',
71
+ data[:active_count] || 0
72
+ )
73
+ end
74
+ end
75
+ else
76
+ # Default view - use same format for header and data
77
+ # rubocop:disable Style/RedundantFormat
78
+ if s3
36
79
  puts format(
37
- '%-30s %-12s %10d %12s %20s %-15s %-10s %-35s %-30s %-10s %6d',
38
- brand_display,
39
- data[:key],
40
- data[:count],
41
- format_size(data[:size]),
42
- format_date(data[:modified]),
43
- data[:git_status],
44
- data[:s3_sync],
45
- shorten_path(data[:path]),
46
- data[:ssd_backup] || 'N/A',
47
- data[:workflow] || 'N/A',
48
- data[:active_count] || 0
80
+ '%-30s %-15s %10s %12s %20s %-15s %-10s',
81
+ 'BRAND',
82
+ 'KEY',
83
+ 'PROJECTS',
84
+ 'SIZE',
85
+ 'LAST MODIFIED',
86
+ 'GIT',
87
+ 'S3 SYNC'
49
88
  )
89
+ puts '-' * 133
90
+ else
91
+ puts format(
92
+ '%-30s %-15s %10s %12s %20s %-15s',
93
+ 'BRAND',
94
+ 'KEY',
95
+ 'PROJECTS',
96
+ 'SIZE',
97
+ 'LAST MODIFIED',
98
+ 'GIT'
99
+ )
100
+ puts '-' * 122
50
101
  end
51
- else
52
- # Default view
53
- puts 'BRAND KEY PROJECTS SIZE LAST MODIFIED GIT S3 SYNC'
54
- puts '-' * 130
102
+ # rubocop:enable Style/RedundantFormat
55
103
 
56
104
  brand_data.each do |data|
57
105
  brand_display = "#{data[:shortcut]} - #{data[:name]}"
58
106
 
59
- puts format(
60
- '%-30s %-12s %10d %12s %20s %-15s %-10s',
61
- brand_display,
62
- data[:key],
63
- data[:count],
64
- format_size(data[:size]),
65
- format_date(data[:modified]),
66
- data[:git_status],
67
- data[:s3_sync]
68
- )
107
+ if s3
108
+ puts format(
109
+ '%-30s %-15s %10d %12s %20s %-15s %-10s',
110
+ brand_display,
111
+ data[:key],
112
+ data[:count],
113
+ format_size(data[:size]),
114
+ format_date(data[:modified]),
115
+ data[:git_status],
116
+ data[:s3_sync]
117
+ )
118
+ else
119
+ puts format(
120
+ '%-30s %-15s %10d %12s %20s %-15s',
121
+ brand_display,
122
+ data[:key],
123
+ data[:count],
124
+ format_size(data[:size]),
125
+ format_date(data[:modified]),
126
+ data[:git_status]
127
+ )
128
+ end
69
129
  end
70
130
  end
71
131
 
@@ -82,7 +142,7 @@ module Appydave
82
142
 
83
143
  # List all projects for a specific brand (Mode 3)
84
144
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
85
- def self.list_brand_projects(brand_arg, detailed: false)
145
+ def self.list_brand_projects(brand_arg, detailed: false, s3: false)
86
146
  # ProjectResolver expects the original brand key/shortcut, not the expanded v-* version
87
147
  projects = ProjectResolver.list_projects(brand_arg)
88
148
 
@@ -103,13 +163,13 @@ module Appydave
103
163
  return
104
164
  end
105
165
 
106
- # Gather project data
166
+ # Gather project data (skip S3 if not requested)
107
167
  brand_path = Config.brand_path(brand_arg)
108
168
  brand_info = Appydave::Tools::Configuration::Config.brands.get_brand(brand_arg)
109
169
  is_git_repo = Dir.exist?(File.join(brand_path, '.git'))
110
170
 
111
171
  project_data = projects.map do |project|
112
- collect_project_data(brand_arg, brand_path, brand_info, project, is_git_repo, detailed: detailed)
172
+ collect_project_data(brand_arg, brand_path, brand_info, project, is_git_repo, detailed: detailed, s3: s3)
113
173
  end
114
174
 
115
175
  # Print common header
@@ -119,48 +179,121 @@ module Appydave
119
179
  puts ''
120
180
 
121
181
  if detailed
122
- # Detailed view with additional columns
123
- header = 'PROJECT SIZE AGE GIT S3 ' \
124
- 'PATH HEAVY FILES LIGHT FILES SSD BACKUP ' \
125
- 'S3 ↑ UPLOAD S3 ↓ DOWNLOAD'
126
- puts header
127
- puts '-' * 250
182
+ # Detailed view with additional columns - use same format for header and data
183
+ # rubocop:disable Style/RedundantFormat
184
+ if s3
185
+ puts format(
186
+ '%-45s %12s %15s %-15s %-12s %-65s %-18s %-18s %-30s %-15s %-15s',
187
+ 'PROJECT',
188
+ 'SIZE',
189
+ 'AGE',
190
+ 'GIT',
191
+ 'S3',
192
+ 'PATH',
193
+ 'HEAVY FILES',
194
+ 'LIGHT FILES',
195
+ 'SSD BACKUP',
196
+ 'S3 ↑ UPLOAD',
197
+ 'S3 ↓ DOWNLOAD'
198
+ )
199
+ puts '-' * 280
200
+ else
201
+ puts format(
202
+ '%-45s %12s %15s %-15s %-65s %-18s %-18s %-30s',
203
+ 'PROJECT',
204
+ 'SIZE',
205
+ 'AGE',
206
+ 'GIT',
207
+ 'PATH',
208
+ 'HEAVY FILES',
209
+ 'LIGHT FILES',
210
+ 'SSD BACKUP'
211
+ )
212
+ puts '-' * 239
213
+ end
214
+ # rubocop:enable Style/RedundantFormat
128
215
 
129
216
  project_data.each do |data|
130
217
  age_display = data[:stale] ? "#{data[:age]} ⚠️" : data[:age]
131
- s3_upload = data[:s3_last_upload] ? format_age(data[:s3_last_upload]) : 'N/A'
132
- s3_download = data[:s3_last_download] ? format_age(data[:s3_last_download]) : 'N/A'
133
218
 
219
+ if s3
220
+ s3_upload = data[:s3_last_upload] ? format_age(data[:s3_last_upload]) : 'N/A'
221
+ s3_download = data[:s3_last_download] ? format_age(data[:s3_last_download]) : 'N/A'
222
+
223
+ puts format(
224
+ '%-45s %12s %15s %-15s %-12s %-65s %-18s %-18s %-30s %-15s %-15s',
225
+ data[:name],
226
+ format_size(data[:size]),
227
+ age_display,
228
+ data[:git_status],
229
+ data[:s3_sync],
230
+ shorten_path(data[:path]),
231
+ data[:heavy_files] || 'N/A',
232
+ data[:light_files] || 'N/A',
233
+ data[:ssd_backup] || 'N/A',
234
+ s3_upload,
235
+ s3_download
236
+ )
237
+ else
238
+ puts format(
239
+ '%-45s %12s %15s %-15s %-65s %-18s %-18s %-30s',
240
+ data[:name],
241
+ format_size(data[:size]),
242
+ age_display,
243
+ data[:git_status],
244
+ shorten_path(data[:path]),
245
+ data[:heavy_files] || 'N/A',
246
+ data[:light_files] || 'N/A',
247
+ data[:ssd_backup] || 'N/A'
248
+ )
249
+ end
250
+ end
251
+ else
252
+ # Default view - use same format for header and data
253
+ # rubocop:disable Style/RedundantFormat
254
+ if s3
134
255
  puts format(
135
- '%-45s %12s %15s %-15s %-12s %-35s %-18s %-18s %-30s %-15s %-15s',
136
- data[:name],
137
- format_size(data[:size]),
138
- age_display,
139
- data[:git_status],
140
- data[:s3_sync],
141
- shorten_path(data[:path]),
142
- data[:heavy_files] || 'N/A',
143
- data[:light_files] || 'N/A',
144
- data[:ssd_backup] || 'N/A',
145
- s3_upload,
146
- s3_download
256
+ '%-45s %12s %15s %-15s %-12s',
257
+ 'PROJECT',
258
+ 'SIZE',
259
+ 'AGE',
260
+ 'GIT',
261
+ 'S3'
147
262
  )
263
+ puts '-' * 130
264
+ else
265
+ puts format(
266
+ '%-45s %12s %15s %-15s',
267
+ 'PROJECT',
268
+ 'SIZE',
269
+ 'AGE',
270
+ 'GIT'
271
+ )
272
+ puts '-' * 117
148
273
  end
149
- else
150
- # Default view
151
- puts 'PROJECT SIZE AGE GIT S3'
152
- puts '-' * 130
274
+ # rubocop:enable Style/RedundantFormat
153
275
 
154
276
  project_data.each do |data|
155
277
  age_display = data[:stale] ? "#{data[:age]} ⚠️" : data[:age]
156
- puts format(
157
- '%-45s %12s %15s %-15s %-12s',
158
- data[:name],
159
- format_size(data[:size]),
160
- age_display,
161
- data[:git_status],
162
- data[:s3_sync]
163
- )
278
+
279
+ if s3
280
+ puts format(
281
+ '%-45s %12s %15s %-15s %-12s',
282
+ data[:name],
283
+ format_size(data[:size]),
284
+ age_display,
285
+ data[:git_status],
286
+ data[:s3_sync]
287
+ )
288
+ else
289
+ puts format(
290
+ '%-45s %12s %15s %-15s',
291
+ data[:name],
292
+ format_size(data[:size]),
293
+ age_display,
294
+ data[:git_status]
295
+ )
296
+ end
164
297
  end
165
298
  end
166
299
 
@@ -281,7 +414,7 @@ module Appydave
281
414
 
282
415
  # Collect brand data for display
283
416
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
284
- def self.collect_brand_data(brand, detailed: false)
417
+ def self.collect_brand_data(brand, detailed: false, s3: false)
285
418
  Appydave::Tools::Configuration::Config.configure
286
419
  brand_info = Appydave::Tools::Configuration::Config.brands.get_brand(brand)
287
420
  brand_path = Config.brand_path(brand)
@@ -299,8 +432,8 @@ module Appydave
299
432
  # Get git status
300
433
  git_status = calculate_git_status(brand_path)
301
434
 
302
- # Get S3 sync status (count of projects with s3-staging)
303
- s3_sync_status = calculate_s3_sync_status(brand, projects)
435
+ # Get S3 sync status (count of projects with s3-staging) - only if requested
436
+ s3_sync_status = s3 ? calculate_s3_sync_status(brand, projects) : 'N/A'
304
437
 
305
438
  result = {
306
439
  shortcut: shortcut || key,
@@ -417,7 +550,7 @@ module Appydave
417
550
 
418
551
  # Collect project data for display
419
552
  # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists
420
- def self.collect_project_data(brand_arg, brand_path, brand_info, project, is_git_repo, detailed: false)
553
+ def self.collect_project_data(brand_arg, brand_path, brand_info, project, is_git_repo, detailed: false, s3: false)
421
554
  project_path = Config.project_path(brand_arg, project)
422
555
  size = FileHelper.calculate_directory_size(project_path)
423
556
  modified = File.mtime(project_path)
@@ -429,8 +562,8 @@ module Appydave
429
562
  'N/A'
430
563
  end
431
564
 
432
- # Calculate 3-state S3 sync status
433
- s3_sync = calculate_project_s3_sync_status(brand_arg, brand_info, project)
565
+ # Calculate 3-state S3 sync status - only if requested (performance optimization)
566
+ s3_sync = s3 ? calculate_project_s3_sync_status(brand_arg, brand_info, project) : 'N/A'
434
567
 
435
568
  result = {
436
569
  name: project,
@@ -468,16 +601,23 @@ module Appydave
468
601
  File.exist?(ssd_project_path) ? shorten_path(ssd_project_path) : nil
469
602
  end
470
603
 
471
- # S3 timestamps (last upload/download)
472
- s3_timestamps = calculate_s3_timestamps(brand_arg, brand_info, project)
473
-
474
- result.merge!(
475
- heavy_files: "#{heavy_count} (#{format_size(heavy_size)})",
476
- light_files: "#{light_count} (#{format_size(light_size)})",
477
- ssd_backup: ssd_path,
478
- s3_last_upload: s3_timestamps[:last_upload],
479
- s3_last_download: s3_timestamps[:last_download]
480
- )
604
+ # S3 timestamps (last upload/download) - only if requested (performance optimization)
605
+ if s3
606
+ s3_timestamps = calculate_s3_timestamps(brand_arg, brand_info, project)
607
+ result.merge!(
608
+ heavy_files: "#{heavy_count} (#{format_size(heavy_size)})",
609
+ light_files: "#{light_count} (#{format_size(light_size)})",
610
+ ssd_backup: ssd_path,
611
+ s3_last_upload: s3_timestamps[:last_upload],
612
+ s3_last_download: s3_timestamps[:last_download]
613
+ )
614
+ else
615
+ result.merge!(
616
+ heavy_files: "#{heavy_count} (#{format_size(heavy_size)})",
617
+ light_files: "#{light_count} (#{format_size(light_size)})",
618
+ ssd_backup: ssd_path
619
+ )
620
+ end
481
621
  end
482
622
 
483
623
  result
@@ -152,35 +152,37 @@ module Appydave
152
152
 
153
153
  s3_path = build_s3_key(relative_path)
154
154
 
155
- # Check if file already exists with same MD5
156
- local_md5 = file_md5(file)
157
- s3_md5 = s3_file_md5(s3_path)
158
-
159
- if local_md5 == s3_md5
160
- puts " ⏭️ Skipped: #{relative_path} (unchanged)"
161
- skipped += 1
162
- else
163
- # Warn if we're about to overwrite an existing S3 file
164
- if s3_md5 && s3_md5 != local_md5
165
- puts " ⚠️ Warning: #{relative_path} exists in S3 with different content"
166
-
167
- # Try to get S3 timestamp for comparison
168
- s3_file_info = get_s3_file_info(s3_path)
169
- if s3_file_info && s3_file_info['LastModified']
170
- s3_time = s3_file_info['LastModified']
171
- local_time = File.mtime(file)
172
- puts " S3: #{s3_time.strftime('%Y-%m-%d %H:%M')} | Local: #{local_time.strftime('%Y-%m-%d %H:%M')}"
173
-
174
- puts ' ⚠️ S3 file is NEWER than local - you may be overwriting recent changes!' if s3_time > local_time
175
- end
176
- puts ' Uploading will overwrite S3 version...'
155
+ # Check if file already exists in S3 and compare
156
+ s3_info = get_s3_file_info(s3_path)
157
+
158
+ if s3_info
159
+ s3_etag = s3_info['ETag'].gsub('"', '')
160
+ s3_size = s3_info['Size']
161
+ match_status = compare_files(local_file: file, s3_etag: s3_etag, s3_size: s3_size)
162
+
163
+ if match_status == :synced
164
+ comparison_method = multipart_etag?(s3_etag) ? 'size match' : 'unchanged'
165
+ puts " ⏭️ Skipped: #{relative_path} (#{comparison_method})"
166
+ skipped += 1
167
+ next
177
168
  end
178
169
 
179
- if upload_file(file, s3_path, dry_run: dry_run)
180
- uploaded += 1
181
- else
182
- failed += 1
183
- end
170
+ # File exists but content differs - warn before overwriting
171
+ puts " ⚠️ Warning: #{relative_path} exists in S3 with different content"
172
+ puts ' (multipart upload detected - comparing by size)' if multipart_etag?(s3_etag)
173
+
174
+ s3_time = s3_info['LastModified']
175
+ local_time = File.mtime(file)
176
+ puts " S3: #{s3_time.strftime('%Y-%m-%d %H:%M')} | Local: #{local_time.strftime('%Y-%m-%d %H:%M')}"
177
+
178
+ puts ' ⚠️ S3 file is NEWER than local - you may be overwriting recent changes!' if s3_time > local_time
179
+ puts ' Uploading will overwrite S3 version...'
180
+ end
181
+
182
+ if upload_file(file, s3_path, dry_run: dry_run)
183
+ uploaded += 1
184
+ else
185
+ failed += 1
184
186
  end
185
187
  end
186
188
  # rubocop:enable Metrics/BlockLength
@@ -215,41 +217,47 @@ module Appydave
215
217
  skipped = 0
216
218
  failed = 0
217
219
 
220
+ # rubocop:disable Metrics/BlockLength
218
221
  s3_files.each do |s3_file|
219
222
  key = s3_file['Key']
220
223
  relative_path = extract_relative_path(key)
221
224
  local_file = File.join(staging_dir, relative_path)
222
225
 
223
- # Check if file already exists with same MD5
224
- s3_md5 = s3_file['ETag'].gsub('"', '')
225
- local_md5 = File.exist?(local_file) ? file_md5(local_file) : nil
226
+ # Check if file already exists and compare
227
+ s3_etag = s3_file['ETag'].gsub('"', '')
228
+ s3_size = s3_file['Size']
226
229
 
227
- if local_md5 == s3_md5
228
- puts " ⏭️ Skipped: #{relative_path} (unchanged)"
229
- skipped += 1
230
- else
231
- # Warn if we're about to overwrite an existing local file
232
- if local_md5 && local_md5 != s3_md5
233
- puts " ⚠️ Warning: #{relative_path} exists locally with different content"
234
-
235
- # Compare timestamps
236
- if s3_file['LastModified'] && File.exist?(local_file)
237
- s3_time = s3_file['LastModified']
238
- local_time = File.mtime(local_file)
239
- puts " S3: #{s3_time.strftime('%Y-%m-%d %H:%M')} | Local: #{local_time.strftime('%Y-%m-%d %H:%M')}"
240
-
241
- puts ' ⚠️ Local file is NEWER than S3 - you may be overwriting recent changes!' if local_time > s3_time
242
- end
243
- puts ' Downloading will overwrite local version...'
230
+ if File.exist?(local_file)
231
+ match_status = compare_files(local_file: local_file, s3_etag: s3_etag, s3_size: s3_size)
232
+
233
+ if match_status == :synced
234
+ comparison_method = multipart_etag?(s3_etag) ? 'size match' : 'unchanged'
235
+ puts " ⏭️ Skipped: #{relative_path} (#{comparison_method})"
236
+ skipped += 1
237
+ next
244
238
  end
245
239
 
246
- if download_file(key, local_file, dry_run: dry_run)
247
- downloaded += 1
248
- else
249
- failed += 1
240
+ # File exists but content differs - warn before overwriting
241
+ puts " ⚠️ Warning: #{relative_path} exists locally with different content"
242
+ puts ' (multipart upload detected - comparing by size)' if multipart_etag?(s3_etag)
243
+
244
+ if s3_file['LastModified']
245
+ s3_time = s3_file['LastModified']
246
+ local_time = File.mtime(local_file)
247
+ puts " S3: #{s3_time.strftime('%Y-%m-%d %H:%M')} | Local: #{local_time.strftime('%Y-%m-%d %H:%M')}"
248
+
249
+ puts ' ⚠️ Local file is NEWER than S3 - you may be overwriting recent changes!' if local_time > s3_time
250
250
  end
251
+ puts ' Downloading will overwrite local version...'
252
+ end
253
+
254
+ if download_file(key, local_file, dry_run: dry_run)
255
+ downloaded += 1
256
+ else
257
+ failed += 1
251
258
  end
252
259
  end
260
+ # rubocop:enable Metrics/BlockLength
253
261
  puts ''
254
262
  puts '✅ Download complete!'
255
263
  puts " Downloaded: #{downloaded}, Skipped: #{skipped}, Failed: #{failed}"
@@ -322,11 +330,12 @@ module Appydave
322
330
  total_s3_size += s3_size
323
331
  total_local_size += local_size
324
332
 
325
- local_md5 = file_md5(local_file)
326
- s3_md5 = s3_file['ETag'].gsub('"', '')
333
+ s3_etag = s3_file['ETag'].gsub('"', '')
334
+ match_status = compare_files(local_file: local_file, s3_etag: s3_etag, s3_size: s3_size)
327
335
 
328
- if local_md5 == s3_md5
329
- puts " ✓ #{relative_path} (#{file_size_human(s3_size)}) [synced]"
336
+ if match_status == :synced
337
+ status_label = multipart_etag?(s3_etag) ? 'synced*' : 'synced'
338
+ puts " ✓ #{relative_path} (#{file_size_human(s3_size)}) [#{status_label}]"
330
339
  else
331
340
  puts " ⚠️ #{relative_path} (#{file_size_human(s3_size)}) [modified]"
332
341
  end
@@ -526,10 +535,11 @@ module Appydave
526
535
  s3_file = s3_files_map[relative_path]
527
536
 
528
537
  if s3_file
529
- # Compare MD5
530
- local_md5 = file_md5(local_file)
531
- s3_md5 = s3_file['ETag'].gsub('"', '')
532
- needs_upload = true if local_md5 != s3_md5
538
+ # Compare using multipart-aware comparison
539
+ s3_etag = s3_file['ETag'].gsub('"', '')
540
+ s3_size = s3_file['Size']
541
+ match_status = compare_files(local_file: local_file, s3_etag: s3_etag, s3_size: s3_size)
542
+ needs_upload = true if match_status != :synced
533
543
  else
534
544
  # Local file not in S3
535
545
  needs_upload = true
@@ -623,6 +633,47 @@ module Appydave
623
633
  nil
624
634
  end
625
635
 
636
+ # Check if an S3 ETag is from a multipart upload
637
+ # Multipart ETags have format: "hash-partcount" (e.g., "d41d8cd98f00b204e9800998ecf8427e-5")
638
+ def multipart_etag?(etag)
639
+ return false if etag.nil?
640
+
641
+ etag.include?('-')
642
+ end
643
+
644
+ # Compare local file with S3 file, handling multipart ETags
645
+ # Returns: :synced, :modified, or :unknown
646
+ # For multipart uploads, falls back to size comparison since MD5 won't match
647
+ def compare_files(local_file:, s3_etag:, s3_size:)
648
+ return :unknown unless File.exist?(local_file)
649
+ return :unknown if s3_etag.nil?
650
+
651
+ local_size = File.size(local_file)
652
+
653
+ if multipart_etag?(s3_etag)
654
+ # Multipart upload - MD5 comparison won't work, use size
655
+ # Size match is a reasonable proxy for "unchanged" in this context
656
+ local_size == s3_size ? :synced : :modified
657
+ else
658
+ # Standard upload - use MD5 comparison
659
+ local_md5 = file_md5(local_file)
660
+ return :unknown if local_md5.nil?
661
+
662
+ local_md5 == s3_etag ? :synced : :modified
663
+ end
664
+ end
665
+
666
+ # Get S3 file size from path (for upload comparison)
667
+ def s3_file_size(s3_path)
668
+ response = s3_client.head_object(
669
+ bucket: brand_info.aws.s3_bucket,
670
+ key: s3_path
671
+ )
672
+ response.content_length
673
+ rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::ServiceError
674
+ nil
675
+ end
676
+
626
677
  # Upload file to S3
627
678
  def upload_file(local_file, s3_path, dry_run: false)
628
679
  if dry_run
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Appydave
4
4
  module Tools
5
- VERSION = '0.68.0'
5
+ VERSION = '0.70.0'
6
6
  end
7
7
  end
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appydave-tools",
3
- "version": "0.68.0",
3
+ "version": "0.70.0",
4
4
  "description": "AppyDave YouTube Automation Tools",
5
5
  "scripts": {
6
6
  "release": "semantic-release"