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.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/CHANGELOG.md +21 -0
- data/CLAUDE.md +70 -0
- data/bin/dam +18 -6
- data/docs/README.md +1 -0
- data/docs/code-quality/uat-report-2025-01-22.md +341 -0
- data/docs/dam/batch-s3-listing-requirements.md +780 -0
- data/docs/guides/tools/video-file-namer.md +400 -0
- data/lib/appydave/tools/dam/config.rb +6 -1
- data/lib/appydave/tools/dam/project_listing.rb +225 -85
- data/lib/appydave/tools/dam/s3_operations.rb +110 -59
- data/lib/appydave/tools/version.rb +1 -1
- data/package.json +1 -1
- metadata +5 -2
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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 %-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
puts "
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
|
329
|
-
|
|
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
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|