appydave-tools 0.69.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,64 +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
 
36
- 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
49
- )
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
50
74
  end
51
75
  else
52
76
  # Default view - use same format for header and data
53
77
  # rubocop:disable Style/RedundantFormat
54
- puts format(
55
- '%-30s %-15s %10s %12s %20s %-15s %-10s',
56
- 'BRAND',
57
- 'KEY',
58
- 'PROJECTS',
59
- 'SIZE',
60
- 'LAST MODIFIED',
61
- 'GIT',
62
- 'S3 SYNC'
63
- )
78
+ if s3
79
+ puts format(
80
+ '%-30s %-15s %10s %12s %20s %-15s %-10s',
81
+ 'BRAND',
82
+ 'KEY',
83
+ 'PROJECTS',
84
+ 'SIZE',
85
+ 'LAST MODIFIED',
86
+ 'GIT',
87
+ 'S3 SYNC'
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
101
+ end
64
102
  # rubocop:enable Style/RedundantFormat
65
- puts '-' * 133
66
103
 
67
104
  brand_data.each do |data|
68
105
  brand_display = "#{data[:shortcut]} - #{data[:name]}"
69
106
 
70
- puts format(
71
- '%-30s %-15s %10d %12s %20s %-15s %-10s',
72
- brand_display,
73
- data[:key],
74
- data[:count],
75
- format_size(data[:size]),
76
- format_date(data[:modified]),
77
- data[:git_status],
78
- data[:s3_sync]
79
- )
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
80
129
  end
81
130
  end
82
131
 
@@ -93,7 +142,7 @@ module Appydave
93
142
 
94
143
  # List all projects for a specific brand (Mode 3)
95
144
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
96
- def self.list_brand_projects(brand_arg, detailed: false)
145
+ def self.list_brand_projects(brand_arg, detailed: false, s3: false)
97
146
  # ProjectResolver expects the original brand key/shortcut, not the expanded v-* version
98
147
  projects = ProjectResolver.list_projects(brand_arg)
99
148
 
@@ -114,13 +163,13 @@ module Appydave
114
163
  return
115
164
  end
116
165
 
117
- # Gather project data
166
+ # Gather project data (skip S3 if not requested)
118
167
  brand_path = Config.brand_path(brand_arg)
119
168
  brand_info = Appydave::Tools::Configuration::Config.brands.get_brand(brand_arg)
120
169
  is_git_repo = Dir.exist?(File.join(brand_path, '.git'))
121
170
 
122
171
  project_data = projects.map do |project|
123
- 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)
124
173
  end
125
174
 
126
175
  # Print common header
@@ -132,67 +181,119 @@ module Appydave
132
181
  if detailed
133
182
  # Detailed view with additional columns - use same format for header and data
134
183
  # rubocop:disable Style/RedundantFormat
135
- puts format(
136
- '%-45s %12s %15s %-15s %-12s %-65s %-18s %-18s %-30s %-15s %-15s',
137
- 'PROJECT',
138
- 'SIZE',
139
- 'AGE',
140
- 'GIT',
141
- 'S3',
142
- 'PATH',
143
- 'HEAVY FILES',
144
- 'LIGHT FILES',
145
- 'SSD BACKUP',
146
- 'S3 ↑ UPLOAD',
147
- 'S3 DOWNLOAD'
148
- )
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
149
214
  # rubocop:enable Style/RedundantFormat
150
- puts '-' * 280
151
215
 
152
216
  project_data.each do |data|
153
217
  age_display = data[:stale] ? "#{data[:age]} ⚠️" : data[:age]
154
- s3_upload = data[:s3_last_upload] ? format_age(data[:s3_last_upload]) : 'N/A'
155
- s3_download = data[:s3_last_download] ? format_age(data[:s3_last_download]) : 'N/A'
156
218
 
157
- puts format(
158
- '%-45s %12s %15s %-15s %-12s %-65s %-18s %-18s %-30s %-15s %-15s',
159
- data[:name],
160
- format_size(data[:size]),
161
- age_display,
162
- data[:git_status],
163
- data[:s3_sync],
164
- shorten_path(data[:path]),
165
- data[:heavy_files] || 'N/A',
166
- data[:light_files] || 'N/A',
167
- data[:ssd_backup] || 'N/A',
168
- s3_upload,
169
- s3_download
170
- )
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
171
250
  end
172
251
  else
173
252
  # Default view - use same format for header and data
174
253
  # rubocop:disable Style/RedundantFormat
175
- puts format(
176
- '%-45s %12s %15s %-15s %-12s',
177
- 'PROJECT',
178
- 'SIZE',
179
- 'AGE',
180
- 'GIT',
181
- 'S3'
182
- )
254
+ if s3
255
+ puts format(
256
+ '%-45s %12s %15s %-15s %-12s',
257
+ 'PROJECT',
258
+ 'SIZE',
259
+ 'AGE',
260
+ 'GIT',
261
+ 'S3'
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
273
+ end
183
274
  # rubocop:enable Style/RedundantFormat
184
- puts '-' * 130
185
275
 
186
276
  project_data.each do |data|
187
277
  age_display = data[:stale] ? "#{data[:age]} ⚠️" : data[:age]
188
- puts format(
189
- '%-45s %12s %15s %-15s %-12s',
190
- data[:name],
191
- format_size(data[:size]),
192
- age_display,
193
- data[:git_status],
194
- data[:s3_sync]
195
- )
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
196
297
  end
197
298
  end
198
299
 
@@ -313,7 +414,7 @@ module Appydave
313
414
 
314
415
  # Collect brand data for display
315
416
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
316
- def self.collect_brand_data(brand, detailed: false)
417
+ def self.collect_brand_data(brand, detailed: false, s3: false)
317
418
  Appydave::Tools::Configuration::Config.configure
318
419
  brand_info = Appydave::Tools::Configuration::Config.brands.get_brand(brand)
319
420
  brand_path = Config.brand_path(brand)
@@ -331,8 +432,8 @@ module Appydave
331
432
  # Get git status
332
433
  git_status = calculate_git_status(brand_path)
333
434
 
334
- # Get S3 sync status (count of projects with s3-staging)
335
- 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'
336
437
 
337
438
  result = {
338
439
  shortcut: shortcut || key,
@@ -449,7 +550,7 @@ module Appydave
449
550
 
450
551
  # Collect project data for display
451
552
  # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists
452
- 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)
453
554
  project_path = Config.project_path(brand_arg, project)
454
555
  size = FileHelper.calculate_directory_size(project_path)
455
556
  modified = File.mtime(project_path)
@@ -461,8 +562,8 @@ module Appydave
461
562
  'N/A'
462
563
  end
463
564
 
464
- # Calculate 3-state S3 sync status
465
- 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'
466
567
 
467
568
  result = {
468
569
  name: project,
@@ -500,16 +601,23 @@ module Appydave
500
601
  File.exist?(ssd_project_path) ? shorten_path(ssd_project_path) : nil
501
602
  end
502
603
 
503
- # S3 timestamps (last upload/download)
504
- s3_timestamps = calculate_s3_timestamps(brand_arg, brand_info, project)
505
-
506
- result.merge!(
507
- heavy_files: "#{heavy_count} (#{format_size(heavy_size)})",
508
- light_files: "#{light_count} (#{format_size(light_size)})",
509
- ssd_backup: ssd_path,
510
- s3_last_upload: s3_timestamps[:last_upload],
511
- s3_last_download: s3_timestamps[:last_download]
512
- )
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
513
621
  end
514
622
 
515
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.69.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.69.0",
3
+ "version": "0.70.0",
4
4
  "description": "AppyDave YouTube Automation Tools",
5
5
  "scripts": {
6
6
  "release": "semantic-release"