kintsugi 0.1.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.
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