kintsugi 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/kintsugi.gemspec ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "kintsugi"
5
+ spec.version = "0.1.0"
6
+ spec.authors = ["Ben Yohay"]
7
+ spec.email = ["ben@lightricks.com"]
8
+ spec.required_ruby_version = ">= 2.5.0"
9
+ spec.description =
10
+ %q(
11
+ Kintsugi resolves conflicts in .pbxproj files, with the aim to resolve 99.9% of the conflicts
12
+ automatically.
13
+ )
14
+ spec.summary = %q(pbxproj files git conflicts solver)
15
+ spec.homepage = "https://github.com/Lightricks/Kintsugi"
16
+ spec.license = "MIT"
17
+
18
+ spec.files = `git ls-files`.split($/)
19
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
20
+ spec.test_files = spec.files.grep(%r{^(spec)/})
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.add_dependency "xcodeproj", "1.19.0"
24
+
25
+ spec.add_development_dependency "rake", "~> 13.0"
26
+ spec.add_development_dependency "rspec", "~> 3.9"
27
+ spec.add_development_dependency "rubocop", "1.12.0"
28
+ spec.add_development_dependency "rubocop-rspec", "2.2.0"
29
+ spec.add_development_dependency "simplecov", "~> 0.21"
30
+ end
data/lib/kintsugi.rb ADDED
@@ -0,0 +1,119 @@
1
+ # Copyright (c) 2021 Lightricks. All rights reserved.
2
+ # Created by Ben Yohay.
3
+ # frozen_string_literal: true
4
+
5
+ require "tmpdir"
6
+ require "tempfile"
7
+ require "xcodeproj"
8
+
9
+ require_relative "kintsugi/xcodeproj_extensions"
10
+ require_relative "kintsugi/apply_change_to_project"
11
+
12
+ module Kintsugi
13
+ class << self
14
+ # Resolves git conflicts of a pbxproj file specified by `project_file_path`.
15
+ #
16
+ # @param [String] project_file_path
17
+ # Project to which to apply the changes.
18
+ #
19
+ # @param [String] output_changes_path
20
+ # Path to where the changes to apply to the project are written in JSON format.
21
+ #
22
+ # @raise [ArgumentError]
23
+ # If the file extension is not `pbxproj` or the file doesn't exist
24
+ #
25
+ # @raise [RuntimeError]
26
+ # If no rebase, cherry-pick, or merge is in progress, or the project file couldn't be
27
+ # opened, or there was an error applying the change to the project.
28
+ #
29
+ # @return [void]
30
+ def resolve_conflicts(project_file_path, changes_output_path)
31
+ validate_project(project_file_path)
32
+
33
+ project_in_temp_directory =
34
+ open_project_of_current_commit_in_temporary_directory(project_file_path)
35
+
36
+ change = change_of_conflicting_commit_with_parent(project_file_path)
37
+
38
+ if changes_output_path
39
+ File.write(changes_output_path, JSON.pretty_generate(change))
40
+ end
41
+
42
+ apply_change_to_project(project_in_temp_directory, change)
43
+
44
+ project_in_temp_directory.save
45
+
46
+ Dir.chdir(File.dirname(project_file_path)) do
47
+ `git reset #{project_file_path}`
48
+ end
49
+ FileUtils.cp(File.join(project_in_temp_directory.path, "project.pbxproj"), project_file_path)
50
+
51
+ # Some of the metadata in a `pbxproj` file include a part of the name of the directory it's
52
+ # inside. The modified project is stored in a temporary directory and then copied to the
53
+ # original path, therefore its metadata is incorrect. To fix this, the project at the original
54
+ # path is opened and saved.
55
+ Xcodeproj::Project.open(File.dirname(project_file_path)).save
56
+ end
57
+
58
+ private
59
+
60
+ def validate_project(project_file_path)
61
+ if File.extname(project_file_path) != ".pbxproj"
62
+ raise ArgumentError, "Wrong file extension, please provide file with extension .pbxproj\""
63
+ end
64
+
65
+ unless File.exist?(project_file_path)
66
+ raise ArgumentError, "File '#{project_file_path}' doesn't exist"
67
+ end
68
+
69
+ Dir.chdir(File.dirname(project_file_path)) do
70
+ unless file_has_base_ours_and_theirs_versions?(project_file_path)
71
+ raise ArgumentError, "File '#{project_file_path}' doesn't have conflicts, or a 3-way " \
72
+ "merge is not possible."
73
+ end
74
+ end
75
+ end
76
+
77
+ def open_project_of_current_commit_in_temporary_directory(project_file_path)
78
+ temp_project_file_path = File.join(Dir.mktmpdir, "project.pbxproj")
79
+ Dir.chdir(File.dirname(project_file_path)) do
80
+ `git show HEAD:./project.pbxproj > #{temp_project_file_path}`
81
+ end
82
+ Xcodeproj::Project.open(File.dirname(temp_project_file_path))
83
+ end
84
+
85
+ def file_has_base_ours_and_theirs_versions?(file_path)
86
+ Dir.chdir(`git rev-parse --show-toplevel`.strip) do
87
+ file_has_version_in_stage_numbers?(file_path, [1, 2, 3])
88
+ end
89
+ end
90
+
91
+ def file_has_version_in_stage_numbers?(file_path, stage_numbers)
92
+ file_absolute_path = File.absolute_path(file_path)
93
+ actual_stage_numbers =
94
+ `git ls-files -u -- #{file_absolute_path}`.split("\n").map do |git_file_status|
95
+ git_file_status.split[2]
96
+ end
97
+ (stage_numbers - actual_stage_numbers.map(&:to_i)).empty?
98
+ end
99
+
100
+ def change_of_conflicting_commit_with_parent(project_file_path)
101
+ Dir.chdir(File.dirname(project_file_path)) do
102
+ conflicting_commit_project_file_path = File.join(Dir.mktmpdir, "project.pbxproj")
103
+ `git show :3:./project.pbxproj > #{conflicting_commit_project_file_path}`
104
+
105
+ conflicting_commit_parent_project_file_path = File.join(Dir.mktmpdir, "project.pbxproj")
106
+ `git show :1:./project.pbxproj > #{conflicting_commit_parent_project_file_path}`
107
+
108
+ conflicting_commit_project = Xcodeproj::Project.open(
109
+ File.dirname(conflicting_commit_project_file_path)
110
+ )
111
+ conflicting_commit_parent_project =
112
+ Xcodeproj::Project.open(File.dirname(conflicting_commit_parent_project_file_path))
113
+
114
+ Xcodeproj::Differ.project_diff(conflicting_commit_project,
115
+ conflicting_commit_parent_project, :added, :removed)
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,592 @@
1
+ # Copyright (c) 2020 Lightricks. All rights reserved.
2
+ # Created by Ben Yohay.
3
+ # frozen_string_literal: true
4
+
5
+ require "xcodeproj"
6
+
7
+ require_relative "utils"
8
+ require_relative "xcodeproj_extensions"
9
+
10
+ module Kintsugi
11
+ class << self
12
+ # Applies the change specified by `change` to `project`.
13
+ #
14
+ # @param [Xcodeproj::Project] project
15
+ # Project to which to apply the change.
16
+ #
17
+ # @param [Hash] change
18
+ # Change to apply to `project`. Assumed to be in the format emitted by
19
+ # Xcodeproj::Differ#project_diff where its `key_1` and `key_2` parameters have values of
20
+ # `:added` and `:removed` respectively.
21
+ #
22
+ # @return [void]
23
+ def apply_change_to_project(project, change)
24
+ # We iterate over the main group and project references first because they might create file
25
+ # or project references that are referenced in other parts.
26
+ unless change["rootObject"]["mainGroup"].nil?
27
+ if project.root_object.main_group.nil?
28
+ puts "Warning: Main group doesn't exist, ignoring changes to it."
29
+ else
30
+ apply_change_to_component(project.root_object, "mainGroup",
31
+ change["rootObject"]["mainGroup"])
32
+ end
33
+ end
34
+
35
+ unless change["rootObject"]["projectReferences"].nil?
36
+ apply_change_to_component(project.root_object, "projectReferences",
37
+ change["rootObject"]["projectReferences"])
38
+ end
39
+
40
+ apply_change_to_component(project, "rootObject",
41
+ change["rootObject"].reject { |key|
42
+ %w[mainGroup projectReferences].include?(key)
43
+ })
44
+ end
45
+
46
+ private
47
+
48
+ def apply_change_to_component(parent_component, change_name, change)
49
+ return if change_name == "displayName"
50
+
51
+ attribute_name = attribute_name_from_change_name(change_name)
52
+ if simple_attribute?(parent_component, attribute_name)
53
+ apply_change_to_simple_attribute(parent_component, attribute_name, change)
54
+ return
55
+ end
56
+
57
+ if change["isa"]
58
+ component = replace_component_with_new_type(parent_component, attribute_name, change)
59
+ change = change_for_component_of_new_type(component, change)
60
+ else
61
+ component = child_component(parent_component, change_name)
62
+
63
+ if component.nil?
64
+ add_missing_component_if_valid(parent_component, change_name, change)
65
+ return
66
+ end
67
+ end
68
+
69
+ (change[:removed] || []).each do |removed_change|
70
+ child = child_component(component, removed_change["displayName"])
71
+ next if child.nil?
72
+
73
+ remove_component(child, removed_change)
74
+ end
75
+
76
+ (change[:added] || []).each do |added_change|
77
+ is_object_list = component.is_a?(Xcodeproj::Project::ObjectList)
78
+ add_child_to_component(is_object_list ? parent_component : component, added_change)
79
+ end
80
+
81
+ subchanges_of_change(change).each do |subchange_name, subchange|
82
+ apply_change_to_component(component, subchange_name, subchange)
83
+ end
84
+ end
85
+
86
+ def subchanges_of_change(change)
87
+ if change.key?(:diff)
88
+ change[:diff]
89
+ else
90
+ change.reject { |change_name, _| %i[added removed].include?(change_name) }
91
+ end
92
+ end
93
+
94
+ def attribute_name_from_change_name(change_name)
95
+ if change_name == "fileEncoding"
96
+ change_name.to_sym
97
+ else
98
+ Xcodeproj::Project::Object::CaseConverter.convert_to_ruby(change_name)
99
+ end
100
+ end
101
+
102
+ def add_missing_component_if_valid(parent_component, change_name, change)
103
+ if change[:added] && change.compact.count == 1
104
+ add_child_to_component(parent_component, change[:added])
105
+ return
106
+ end
107
+
108
+ puts "Warning: Detected change of an object named '#{change_name}' contained in " \
109
+ "'#{parent_component}' but the object doesn't exist. Ignoring this change."
110
+ end
111
+
112
+ def replace_component_with_new_type(parent_component, name_in_parent_component, change)
113
+ old_component = parent_component.send(name_in_parent_component)
114
+
115
+ new_component = parent_component.project.new(
116
+ Module.const_get("Xcodeproj::Project::#{change["isa"][:added]}")
117
+ )
118
+
119
+ copy_attributes_to_new_component(old_component, new_component)
120
+
121
+ parent_component.send("#{name_in_parent_component}=", new_component)
122
+ new_component
123
+ end
124
+
125
+ def copy_attributes_to_new_component(old_component, new_component)
126
+ # The change won't describe the attributes that haven't changed, therefore the attributes
127
+ # are copied to the new component.
128
+ old_component.attributes.each do |attribute|
129
+ next if %i[isa display_name].include?(attribute.name) ||
130
+ !new_component.respond_to?(attribute.name)
131
+
132
+ new_component.send("#{attribute.name}=", old_component.send(attribute.name))
133
+ end
134
+ end
135
+
136
+ def change_for_component_of_new_type(new_component, change)
137
+ change.select do |subchange_name, _|
138
+ next false if subchange_name == "isa"
139
+
140
+ attribute_name = attribute_name_from_change_name(subchange_name)
141
+ new_component.respond_to?(attribute_name)
142
+ end
143
+ end
144
+
145
+ def child_component(component, change_name)
146
+ if component.is_a?(Xcodeproj::Project::ObjectList)
147
+ component.find { |child| child.display_name == change_name }
148
+ else
149
+ attribute_name = attribute_name_from_change_name(change_name)
150
+ component.send(attribute_name)
151
+ end
152
+ end
153
+
154
+ def simple_attribute?(component, attribute_name)
155
+ return false unless component.respond_to?("simple_attributes")
156
+
157
+ component.simple_attributes.any? { |attribute| attribute.name == attribute_name }
158
+ end
159
+
160
+ def apply_change_to_simple_attribute(component, attribute_name, change)
161
+ new_attribute_value =
162
+ simple_attribute_value_with_change(component.send(attribute_name), change)
163
+ component.send("#{attribute_name}=", new_attribute_value)
164
+ end
165
+
166
+ def simple_attribute_value_with_change(old_value, change)
167
+ new_value = nil
168
+
169
+ if change.key?(:removed)
170
+ new_value = apply_removal_to_simple_attribute(old_value, change[:removed])
171
+ end
172
+
173
+ if change.key?(:added)
174
+ new_value = apply_addition_to_simple_attribute(old_value, change[:added])
175
+ end
176
+
177
+ subchanges_of_change(change).each do |subchange_name, subchange_value|
178
+ new_value = new_value || old_value || {}
179
+ new_value[subchange_name] =
180
+ simple_attribute_value_with_change(old_value[subchange_name], subchange_value)
181
+ end
182
+
183
+ new_value
184
+ end
185
+
186
+ def apply_removal_to_simple_attribute(old_value, change)
187
+ case change
188
+ when Array
189
+ (old_value || []) - change
190
+ when Hash
191
+ (old_value || {}).reject do |key, value|
192
+ if value != change[key]
193
+ raise "Trying to remove value #{change[key]} of hash with key #{key} but it changed " \
194
+ "to #{value}."
195
+ end
196
+
197
+ change.key?(key)
198
+ end
199
+ when String
200
+ if old_value != change
201
+ raise "Value changed from #{old_value} to #{change}."
202
+ end
203
+
204
+ nil
205
+ when nil
206
+ nil
207
+ else
208
+ raise "Unsupported change #{change} of type #{change.class}"
209
+ end
210
+ end
211
+
212
+ def apply_addition_to_simple_attribute(old_value, change)
213
+ case change
214
+ when Array
215
+ (old_value || []) + change
216
+ when Hash
217
+ old_value ||= {}
218
+ new_value = old_value.merge(change)
219
+
220
+ unless (old_value.to_a - new_value.to_a).empty?
221
+ raise "New hash #{change} contains values that conflict with old hash #{old_value}"
222
+ end
223
+
224
+ new_value
225
+ when String
226
+ change
227
+ when nil
228
+ nil
229
+ else
230
+ raise "Unsupported change #{change} of type #{change.class}"
231
+ end
232
+ end
233
+
234
+ def remove_component(component, change)
235
+ if component.to_tree_hash != change
236
+ raise "Trying to remove an object that changed since then. This is considered a conflict " \
237
+ "that should be resolved manually. Name of the object is: '#{component.display_name}'"
238
+ end
239
+
240
+ if change["isa"] == "PBXFileReference"
241
+ remove_build_files_of_file_reference(component, change)
242
+ end
243
+
244
+ component.remove_from_project
245
+ end
246
+
247
+ def remove_build_files_of_file_reference(file_reference, change)
248
+ # Since the build file's display name depends on the file reference, removing the file
249
+ # reference before removing it will change the build file's display name which will not be
250
+ # detected when trying to remove the build file. Therefore, the build files that depend on
251
+ # the file reference are removed prior to removing the file reference.
252
+ file_reference.build_files.each do |build_file|
253
+ build_file.referrers.each do |referrer|
254
+ referrer.remove_build_file(build_file)
255
+ end
256
+ end
257
+ end
258
+
259
+ def add_child_to_component(component, change)
260
+ if change["ProjectRef"] && change["ProductGroup"]
261
+ add_subproject_reference(component, change)
262
+ return
263
+ end
264
+
265
+ case change["isa"]
266
+ when "PBXNativeTarget"
267
+ add_target(component, change)
268
+ when "PBXFileReference"
269
+ add_file_reference(component, change)
270
+ when "PBXGroup"
271
+ add_group(component, change)
272
+ when "PBXContainerItemProxy"
273
+ add_container_item_proxy(component, change)
274
+ when "PBXTargetDependency"
275
+ add_target_dependency(component, change)
276
+ when "PBXBuildFile"
277
+ add_build_file(component, change)
278
+ when "XCConfigurationList"
279
+ add_build_configuration_list(component, change)
280
+ when "XCBuildConfiguration"
281
+ add_build_configuration(component, change)
282
+ when "PBXHeadersBuildPhase"
283
+ add_headers_build_phase(component, change)
284
+ when "PBXSourcesBuildPhase"
285
+ add_sources_build_phase(component, change)
286
+ when "PBXCopyFilesBuildPhase"
287
+ add_copy_files_build_phase(component, change)
288
+ when "PBXShellScriptBuildPhase"
289
+ add_shell_script_build_phase(component, change)
290
+ when "PBXFrameworksBuildPhase"
291
+ add_frameworks_build_phase(component, change)
292
+ when "PBXResourcesBuildPhase"
293
+ add_resources_build_phase(component, change)
294
+ when "PBXBuildRule"
295
+ add_build_rule(component, change)
296
+ when "PBXVariantGroup"
297
+ add_variant_group(component, change)
298
+ when "PBXReferenceProxy"
299
+ add_reference_proxy(component, change)
300
+ else
301
+ raise "Trying to add unsupported component type #{change["isa"]}. Full component change " \
302
+ "is: #{change}"
303
+ end
304
+ end
305
+
306
+ def add_reference_proxy(containing_component, change)
307
+ case containing_component
308
+ when Xcodeproj::Project::PBXBuildFile
309
+ containing_component.file_ref = find_file(containing_component.project, change)
310
+ when Xcodeproj::Project::PBXGroup
311
+ reference_proxy = containing_component.project.new(Xcodeproj::Project::PBXReferenceProxy)
312
+ containing_component << reference_proxy
313
+ add_attributes_to_component(reference_proxy, change)
314
+ else
315
+ raise "Trying to add reference proxy to an unsupported component type " \
316
+ "#{containing_component.isa}. Change is: #{change}"
317
+ end
318
+ end
319
+
320
+ def add_variant_group(containing_component, change)
321
+ case containing_component
322
+ when Xcodeproj::Project::PBXBuildFile
323
+ containing_component.file_ref =
324
+ find_variant_group(containing_component.project, change["displayName"])
325
+ when Xcodeproj::Project::PBXGroup, Xcodeproj::Project::PBXVariantGroup
326
+ variant_group = containing_component.project.new(Xcodeproj::Project::PBXVariantGroup)
327
+ containing_component.children << variant_group
328
+ add_attributes_to_component(variant_group, change)
329
+ else
330
+ raise "Trying to add variant group to an unsupported component type " \
331
+ "#{containing_component.isa}. Change is: #{change}"
332
+ end
333
+ end
334
+
335
+ def add_build_rule(target, change)
336
+ build_rule = target.project.new(Xcodeproj::Project::PBXBuildRule)
337
+ target.build_rules << build_rule
338
+ add_attributes_to_component(build_rule, change)
339
+ end
340
+
341
+ def add_shell_script_build_phase(target, change)
342
+ build_phase = target.new_shell_script_build_phase(change["displayName"])
343
+ add_attributes_to_component(build_phase, change)
344
+ end
345
+
346
+ def add_headers_build_phase(target, change)
347
+ add_attributes_to_component(target.headers_build_phase, change)
348
+ end
349
+
350
+ def add_sources_build_phase(target, change)
351
+ add_attributes_to_component(target.source_build_phase, change)
352
+ end
353
+
354
+ def add_frameworks_build_phase(target, change)
355
+ add_attributes_to_component(target.frameworks_build_phase, change)
356
+ end
357
+
358
+ def add_resources_build_phase(target, change)
359
+ add_attributes_to_component(target.resources_build_phase, change)
360
+ end
361
+
362
+ def add_copy_files_build_phase(target, change)
363
+ copy_files_phase_name = change["displayName"] == "CopyFiles" ? nil : change["displayName"]
364
+ copy_files_phase = target.new_copy_files_build_phase(copy_files_phase_name)
365
+
366
+ add_attributes_to_component(copy_files_phase, change)
367
+ end
368
+
369
+ def add_build_configuration_list(target, change)
370
+ target.build_configuration_list = target.project.new(Xcodeproj::Project::XCConfigurationList)
371
+ add_attributes_to_component(target.build_configuration_list, change)
372
+ end
373
+
374
+ def add_build_configuration(configuration_list, change)
375
+ build_configuration = configuration_list.project.new(Xcodeproj::Project::XCBuildConfiguration)
376
+ configuration_list.build_configurations << build_configuration
377
+ add_attributes_to_component(build_configuration, change)
378
+ end
379
+
380
+ def add_build_file(build_phase, change)
381
+ if change["fileRef"].nil?
382
+ puts "Warning: Trying to add a build file without any file reference to build phase " \
383
+ "'#{build_phase}'"
384
+ return
385
+ end
386
+
387
+ build_file = build_phase.project.new(Xcodeproj::Project::PBXBuildFile)
388
+ build_phase.files << build_file
389
+ add_attributes_to_component(build_file, change)
390
+ end
391
+
392
+ def find_variant_group(project, display_name)
393
+ project.objects.find do |object|
394
+ object.isa == "PBXVariantGroup" && object.display_name == display_name
395
+ end
396
+ end
397
+
398
+ def add_target_dependency(target, change)
399
+ target_dependency = find_target(target.project, change["displayName"])
400
+
401
+ if target_dependency
402
+ target.add_dependency(target_dependency)
403
+ return
404
+ end
405
+
406
+ target_dependency = target.project.new(Xcodeproj::Project::PBXTargetDependency)
407
+
408
+ target.dependencies << target_dependency
409
+ add_attributes_to_component(target_dependency, change)
410
+ end
411
+
412
+ def find_target(project, display_name)
413
+ project.targets.find { |target| target.display_name == display_name }
414
+ end
415
+
416
+ def add_container_item_proxy(component, change)
417
+ container_proxy = component.project.new(Xcodeproj::Project::PBXContainerItemProxy)
418
+ container_proxy.container_portal = find_containing_project_uuid(component.project, change)
419
+
420
+ case component.isa
421
+ when "PBXTargetDependency"
422
+ component.target_proxy = container_proxy
423
+ when "PBXReferenceProxy"
424
+ component.remote_ref = container_proxy
425
+ else
426
+ raise "Trying to add container item proxy to an unsupported component type " \
427
+ "#{containing_component.isa}. Change is: #{change}"
428
+ end
429
+ add_attributes_to_component(container_proxy, change, ignore_keys: ["containerPortal"])
430
+ end
431
+
432
+ def find_containing_project_uuid(project, container_item_proxy_change)
433
+ if project.objects_by_uuid[container_item_proxy_change["containerPortal"]]
434
+ return container_item_proxy_change["containerPortal"]
435
+ end
436
+
437
+ # The `containerPortal` from `container_item_proxy_change` might not be relevant, since when a
438
+ # project is added its UUID is generated. Instead, existing container item proxies are
439
+ # searched, until one that has the same remote info as the one in
440
+ # `container_item_proxy_change` is found.
441
+ container_item_proxies =
442
+ project.root_object.project_references.map do |project_ref_and_products|
443
+ project_ref_and_products[:project_ref].proxy_containers.find do |container_proxy|
444
+ container_proxy.remote_info == container_item_proxy_change["remoteInfo"]
445
+ end
446
+ end.compact
447
+
448
+ if container_item_proxies.length > 1
449
+ puts "Debug: Found more than one potential dependency with name " \
450
+ "'#{container_item_proxy_change["remoteInfo"]}'. Using the first one."
451
+ elsif container_item_proxies.empty?
452
+ puts "Warning: No container portal was found for dependency with name " \
453
+ "'#{container_item_proxy_change["remoteInfo"]}'."
454
+ return
455
+ end
456
+
457
+ container_item_proxies.first.container_portal
458
+ end
459
+
460
+ def add_subproject_reference(root_object, project_reference_change)
461
+ subproject_reference = find_file(root_object.project, project_reference_change["ProjectRef"])
462
+
463
+ attribute =
464
+ Xcodeproj::Project::PBXProject.references_by_keys_attributes
465
+ .find { |attrb| attrb.name == :project_references }
466
+ project_reference = Xcodeproj::Project::ObjectDictionary.new(attribute, root_object)
467
+ project_reference[:project_ref] = subproject_reference
468
+ root_object.project_references << project_reference
469
+
470
+ updated_project_reference_change =
471
+ change_with_updated_subproject_uuid(project_reference_change, subproject_reference.uuid)
472
+ add_attributes_to_component(project_reference, updated_project_reference_change,
473
+ ignore_keys: ["ProjectRef"])
474
+ end
475
+
476
+ def change_with_updated_subproject_uuid(change, subproject_reference_uuid)
477
+ new_change = change.deep_clone
478
+ new_change["ProductGroup"]["children"].map do |product_reference_change|
479
+ product_reference_change["remoteRef"]["containerPortal"] = subproject_reference_uuid
480
+ product_reference_change
481
+ end
482
+ new_change
483
+ end
484
+
485
+ def add_target(root_object, change)
486
+ target = root_object.project.new(Xcodeproj::Project::PBXNativeTarget)
487
+ root_object.project.targets << target
488
+ add_attributes_to_component(target, change)
489
+ end
490
+
491
+ def add_file_reference(containing_component, change)
492
+ # base configuration reference and product reference always reference a file that exists
493
+ # inside a group, therefore in these cases the file is searched for.
494
+ # In the case of group and variant group, the file can't exist in another group, therefore a
495
+ # new file reference is always created.
496
+ case containing_component
497
+ when Xcodeproj::Project::XCBuildConfiguration
498
+ containing_component.base_configuration_reference =
499
+ find_file(containing_component.project, change)
500
+ when Xcodeproj::Project::PBXNativeTarget
501
+ containing_component.product_reference = find_file(containing_component.project, change)
502
+ when Xcodeproj::Project::PBXBuildFile
503
+ containing_component.file_ref = find_file(containing_component.project, change)
504
+ when Xcodeproj::Project::PBXGroup, Xcodeproj::Project::PBXVariantGroup
505
+ file_reference = containing_component.project.new(Xcodeproj::Project::PBXFileReference)
506
+ containing_component.children << file_reference
507
+
508
+ # For some reason, `include_in_index` is set to `1` by default.
509
+ file_reference.include_in_index = nil
510
+ add_attributes_to_component(file_reference, change)
511
+ else
512
+ raise "Trying to add file reference to an unsupported component type " \
513
+ "#{containing_component.isa}. Change is: #{change}"
514
+ end
515
+ end
516
+
517
+ def add_group(containing_component, change)
518
+ case containing_component
519
+ when Xcodeproj::Project::ObjectDictionary
520
+ # It is assumed that an `ObjectDictionary` always represents a project reference.
521
+ new_group = containing_component[:project_ref].project.new(Xcodeproj::Project::PBXGroup)
522
+ containing_component[:product_group] = new_group
523
+ when Xcodeproj::Project::PBXGroup
524
+ new_group = containing_component.project.new(Xcodeproj::Project::PBXGroup)
525
+ containing_component.children << new_group
526
+ else
527
+ raise "Trying to add group to an unsupported component type #{containing_component.isa}. " \
528
+ "Change is: #{change}"
529
+ end
530
+
531
+ add_attributes_to_component(new_group, change)
532
+ end
533
+
534
+ def add_attributes_to_component(component, change, ignore_keys: [])
535
+ change.each do |change_name, change_value|
536
+ next if (%w[isa displayName] + ignore_keys).include?(change_name)
537
+
538
+ attribute_name = attribute_name_from_change_name(change_name)
539
+ if simple_attribute?(component, attribute_name)
540
+ apply_change_to_simple_attribute(component, attribute_name, {added: change_value})
541
+ next
542
+ end
543
+
544
+ case change_value
545
+ when Hash
546
+ add_child_to_component(component, change_value)
547
+ when Array
548
+ change_value.each do |added_attribute_element|
549
+ add_child_to_component(component, added_attribute_element)
550
+ end
551
+ else
552
+ raise "Trying to add attribute of unsupported type '#{change_value.class}' to " \
553
+ "object #{component}. Attribute name is '#{change_name}'"
554
+ end
555
+ end
556
+ end
557
+
558
+ def find_file(project, file_reference_change)
559
+ case file_reference_change["isa"]
560
+ when "PBXFileReference"
561
+ project.files.find do |file_reference|
562
+ next file_reference.path == file_reference_change["path"]
563
+ end
564
+ when "PBXReferenceProxy"
565
+ find_reference_proxy(project, file_reference_change["remoteRef"])
566
+ else
567
+ raise "Unsupported file reference change of type #{file_reference["isa"]}."
568
+ end
569
+ end
570
+
571
+ def find_reference_proxy(project, container_item_proxy_change)
572
+ reference_proxies = project.root_object.project_references.map do |project_ref_and_products|
573
+ project_ref_and_products[:product_group].children.find do |product|
574
+ product.remote_ref.remote_global_id_string ==
575
+ container_item_proxy_change["remoteGlobalIDString"] &&
576
+ product.remote_ref.remote_info == container_item_proxy_change["remoteInfo"]
577
+ end
578
+ end.compact
579
+
580
+ if reference_proxies.length > 1
581
+ puts "Debug: Found more than one matching reference proxy with name " \
582
+ "'#{container_item_proxy_change["remoteInfo"]}'. Using the first one."
583
+ elsif reference_proxies.empty?
584
+ puts "Warning: No reference proxy was found for name " \
585
+ "'#{container_item_proxy_change["remoteInfo"]}'."
586
+ return
587
+ end
588
+
589
+ reference_proxies.first
590
+ end
591
+ end
592
+ end