module Kintsugi
Constants
- PROJECT_FILE_NAME
Public Class Methods
Applies the change specified by `change` to `project`.
@param [Xcodeproj::Project] project
Project to which to apply the change.
@param [Hash] change
Change to apply to `project`. Assumed to be in the format emitted by Xcodeproj::Differ#project_diff where its `key_1` and `key_2` parameters have values of `:added` and `:removed` respectively.
@return [void]
# File lib/kintsugi/apply_change_to_project.rb, line 23 def apply_change_to_project(project, change) # We iterate over the main group and project references first because they might create file # or project references that are referenced in other parts. unless change["rootObject"]["mainGroup"].nil? if project.root_object.main_group.nil? puts "Warning: Main group doesn't exist, ignoring changes to it." else apply_change_to_component(project.root_object, "mainGroup", change["rootObject"]["mainGroup"]) end end unless change["rootObject"]["projectReferences"].nil? apply_change_to_component(project.root_object, "projectReferences", change["rootObject"]["projectReferences"]) end apply_change_to_component(project, "rootObject", change["rootObject"].reject { |key| %w[mainGroup projectReferences].include?(key) }) end
Resolves git conflicts of a pbxproj file specified by `project_file_path`.
@param [String] project_file_path
Project to which to apply the changes.
@param [String] output_changes_path
Path to where the changes to apply to the project are written in JSON format.
@raise [ArgumentError]
If the file extension is not `pbxproj` or the file doesn't exist
@raise [RuntimeError]
If no rebase, cherry-pick, or merge is in progress, or the project file couldn't be opened, or there was an error applying the change to the project.
@return [void]
# File lib/kintsugi.rb, line 30 def resolve_conflicts(project_file_path, changes_output_path) validate_project(project_file_path) project_in_temp_directory = open_project_of_current_commit_in_temporary_directory(project_file_path) change = change_of_conflicting_commit_with_parent(project_file_path) if changes_output_path File.write(changes_output_path, JSON.pretty_generate(change)) end apply_change_and_copy_to_original_path(project_in_temp_directory, change, project_file_path) end
Merges the changes done between `theirs_project_path` and `base_project_path` to the file at `ours_project_path`. The files may not be at the original path, and therefore the `original_project_path` is required in order for the project metadata to be written properly.
@param [String] base_project_path
Path to the base version of the project.
@param [String] ours_project_path
Path to ours version of the project.
@param [String] theirs_project_path
Path to theirs version of the project.
@param [String] original_project_path
Path to the original path of the file.
@raise [RuntimeError]
If there was an error applying the change to the project.
@return [void]
# File lib/kintsugi.rb, line 65 def three_way_merge(base_project_path, ours_project_path, theirs_project_path, original_project_path) original_directory_name = File.basename(File.dirname(original_project_path)) base_temporary_project = copy_project_to_temporary_path_in_directory_with_name(base_project_path, original_directory_name) ours_temporary_project = copy_project_to_temporary_path_in_directory_with_name(ours_project_path, original_directory_name) theirs_temporary_project = copy_project_to_temporary_path_in_directory_with_name(theirs_project_path, original_directory_name) change = Xcodeproj::Differ.project_diff(theirs_temporary_project, base_temporary_project, :added, :removed) apply_change_and_copy_to_original_path(ours_temporary_project, change, ours_project_path) end
Private Class Methods
# File lib/kintsugi/apply_change_to_project.rb, line 535 def add_attributes_to_component(component, change, ignore_keys: []) change.each do |change_name, change_value| next if (%w[isa displayName] + ignore_keys).include?(change_name) attribute_name = attribute_name_from_change_name(change_name) if simple_attribute?(component, attribute_name) apply_change_to_simple_attribute(component, attribute_name, {added: change_value}) next end case change_value when Hash add_child_to_component(component, change_value) when Array change_value.each do |added_attribute_element| add_child_to_component(component, added_attribute_element) end else raise "Trying to add attribute of unsupported type '#{change_value.class}' to " \ "object #{component}. Attribute name is '#{change_name}'" end end end
# File lib/kintsugi/apply_change_to_project.rb, line 375 def add_build_configuration(configuration_list, change) build_configuration = configuration_list.project.new(Xcodeproj::Project::XCBuildConfiguration) configuration_list.build_configurations << build_configuration add_attributes_to_component(build_configuration, change) end
# File lib/kintsugi/apply_change_to_project.rb, line 370 def add_build_configuration_list(target, change) target.build_configuration_list = target.project.new(Xcodeproj::Project::XCConfigurationList) add_attributes_to_component(target.build_configuration_list, change) end
# File lib/kintsugi/apply_change_to_project.rb, line 381 def add_build_file(build_phase, change) if change["fileRef"].nil? puts "Warning: Trying to add a build file without any file reference to build phase " \ "'#{build_phase}'" return end build_file = build_phase.project.new(Xcodeproj::Project::PBXBuildFile) build_phase.files << build_file add_attributes_to_component(build_file, change) end
# File lib/kintsugi/apply_change_to_project.rb, line 336 def add_build_rule(target, change) build_rule = target.project.new(Xcodeproj::Project::PBXBuildRule) target.build_rules << build_rule add_attributes_to_component(build_rule, change) end
# File lib/kintsugi/apply_change_to_project.rb, line 260 def add_child_to_component(component, change) if change["ProjectRef"] && change["ProductGroup"] add_subproject_reference(component, change) return end case change["isa"] when "PBXNativeTarget" add_target(component, change) when "PBXFileReference" add_file_reference(component, change) when "PBXGroup" add_group(component, change) when "PBXContainerItemProxy" add_container_item_proxy(component, change) when "PBXTargetDependency" add_target_dependency(component, change) when "PBXBuildFile" add_build_file(component, change) when "XCConfigurationList" add_build_configuration_list(component, change) when "XCBuildConfiguration" add_build_configuration(component, change) when "PBXHeadersBuildPhase" add_headers_build_phase(component, change) when "PBXSourcesBuildPhase" add_sources_build_phase(component, change) when "PBXCopyFilesBuildPhase" add_copy_files_build_phase(component, change) when "PBXShellScriptBuildPhase" add_shell_script_build_phase(component, change) when "PBXFrameworksBuildPhase" add_frameworks_build_phase(component, change) when "PBXResourcesBuildPhase" add_resources_build_phase(component, change) when "PBXBuildRule" add_build_rule(component, change) when "PBXVariantGroup" add_variant_group(component, change) when "PBXReferenceProxy" add_reference_proxy(component, change) else raise "Trying to add unsupported component type #{change["isa"]}. Full component change " \ "is: #{change}" end end
# File lib/kintsugi/apply_change_to_project.rb, line 417 def add_container_item_proxy(component, change) container_proxy = component.project.new(Xcodeproj::Project::PBXContainerItemProxy) container_proxy.container_portal = find_containing_project_uuid(component.project, change) case component.isa when "PBXTargetDependency" component.target_proxy = container_proxy when "PBXReferenceProxy" component.remote_ref = container_proxy else raise "Trying to add container item proxy to an unsupported component type " \ "#{containing_component.isa}. Change is: #{change}" end add_attributes_to_component(container_proxy, change, ignore_keys: ["containerPortal"]) end
# File lib/kintsugi/apply_change_to_project.rb, line 363 def add_copy_files_build_phase(target, change) copy_files_phase_name = change["displayName"] == "CopyFiles" ? nil : change["displayName"] copy_files_phase = target.new_copy_files_build_phase(copy_files_phase_name) add_attributes_to_component(copy_files_phase, change) end
# File lib/kintsugi/apply_change_to_project.rb, line 492 def add_file_reference(containing_component, change) # base configuration reference and product reference always reference a file that exists # inside a group, therefore in these cases the file is searched for. # In the case of group and variant group, the file can't exist in another group, therefore a # new file reference is always created. case containing_component when Xcodeproj::Project::XCBuildConfiguration containing_component.base_configuration_reference = find_file(containing_component.project, change) when Xcodeproj::Project::PBXNativeTarget containing_component.product_reference = find_file(containing_component.project, change) when Xcodeproj::Project::PBXBuildFile containing_component.file_ref = find_file(containing_component.project, change) when Xcodeproj::Project::PBXGroup, Xcodeproj::Project::PBXVariantGroup file_reference = containing_component.project.new(Xcodeproj::Project::PBXFileReference) containing_component.children << file_reference # For some reason, `include_in_index` is set to `1` by default. file_reference.include_in_index = nil add_attributes_to_component(file_reference, change) else raise "Trying to add file reference to an unsupported component type " \ "#{containing_component.isa}. Change is: #{change}" end end
# File lib/kintsugi/apply_change_to_project.rb, line 355 def add_frameworks_build_phase(target, change) add_attributes_to_component(target.frameworks_build_phase, change) end
# File lib/kintsugi/apply_change_to_project.rb, line 518 def add_group(containing_component, change) case containing_component when Xcodeproj::Project::ObjectDictionary # It is assumed that an `ObjectDictionary` always represents a project reference. new_group = containing_component[:project_ref].project.new(Xcodeproj::Project::PBXGroup) containing_component[:product_group] = new_group when Xcodeproj::Project::PBXGroup new_group = containing_component.project.new(Xcodeproj::Project::PBXGroup) containing_component.children << new_group else raise "Trying to add group to an unsupported component type #{containing_component.isa}. " \ "Change is: #{change}" end add_attributes_to_component(new_group, change) end
# File lib/kintsugi/apply_change_to_project.rb, line 347 def add_headers_build_phase(target, change) add_attributes_to_component(target.headers_build_phase, change) end
# File lib/kintsugi/apply_change_to_project.rb, line 102 def add_missing_component_if_valid(parent_component, change_name, change) if change[:added] && change.compact.count == 1 add_child_to_component(parent_component, change[:added]) return end puts "Warning: Detected change of an object named '#{change_name}' contained in " \ "'#{parent_component}' but the object doesn't exist. Ignoring this change." end
# File lib/kintsugi/apply_change_to_project.rb, line 307 def add_reference_proxy(containing_component, change) case containing_component when Xcodeproj::Project::PBXBuildFile containing_component.file_ref = find_file(containing_component.project, change) when Xcodeproj::Project::PBXGroup reference_proxy = containing_component.project.new(Xcodeproj::Project::PBXReferenceProxy) containing_component << reference_proxy add_attributes_to_component(reference_proxy, change) else raise "Trying to add reference proxy to an unsupported component type " \ "#{containing_component.isa}. Change is: #{change}" end end
# File lib/kintsugi/apply_change_to_project.rb, line 359 def add_resources_build_phase(target, change) add_attributes_to_component(target.resources_build_phase, change) end
# File lib/kintsugi/apply_change_to_project.rb, line 342 def add_shell_script_build_phase(target, change) build_phase = target.new_shell_script_build_phase(change["displayName"]) add_attributes_to_component(build_phase, change) end
# File lib/kintsugi/apply_change_to_project.rb, line 351 def add_sources_build_phase(target, change) add_attributes_to_component(target.source_build_phase, change) end
# File lib/kintsugi/apply_change_to_project.rb, line 461 def add_subproject_reference(root_object, project_reference_change) subproject_reference = find_file(root_object.project, project_reference_change["ProjectRef"]) attribute = Xcodeproj::Project::PBXProject.references_by_keys_attributes .find { |attrb| attrb.name == :project_references } project_reference = Xcodeproj::Project::ObjectDictionary.new(attribute, root_object) project_reference[:project_ref] = subproject_reference root_object.project_references << project_reference updated_project_reference_change = change_with_updated_subproject_uuid(project_reference_change, subproject_reference.uuid) add_attributes_to_component(project_reference, updated_project_reference_change, ignore_keys: ["ProjectRef"]) end
# File lib/kintsugi/apply_change_to_project.rb, line 486 def add_target(root_object, change) target = root_object.project.new(Xcodeproj::Project::PBXNativeTarget) root_object.project.targets << target add_attributes_to_component(target, change) end
# File lib/kintsugi/apply_change_to_project.rb, line 399 def add_target_dependency(target, change) target_dependency = find_target(target.project, change["displayName"]) if target_dependency target.add_dependency(target_dependency) return end target_dependency = target.project.new(Xcodeproj::Project::PBXTargetDependency) target.dependencies << target_dependency add_attributes_to_component(target_dependency, change) end
# File lib/kintsugi/apply_change_to_project.rb, line 321 def add_variant_group(containing_component, change) case containing_component when Xcodeproj::Project::PBXBuildFile containing_component.file_ref = find_variant_group(containing_component.project, change["displayName"]) when Xcodeproj::Project::PBXGroup, Xcodeproj::Project::PBXVariantGroup variant_group = containing_component.project.new(Xcodeproj::Project::PBXVariantGroup) containing_component.children << variant_group add_attributes_to_component(variant_group, change) else raise "Trying to add variant group to an unsupported component type " \ "#{containing_component.isa}. Change is: #{change}" end end
# File lib/kintsugi/apply_change_to_project.rb, line 213 def apply_addition_to_simple_attribute(old_value, change) case change when Array (old_value || []) + change when Hash old_value ||= {} new_value = old_value.merge(change) unless (old_value.to_a - new_value.to_a).empty? raise "New hash #{change} contains values that conflict with old hash #{old_value}" end new_value when String change when nil nil else raise "Unsupported change #{change} of type #{change.class}" end end
# File lib/kintsugi.rb, line 89 def apply_change_and_copy_to_original_path(project, change, original_project_file_path) apply_change_to_project(project, change) project.save FileUtils.cp(File.join(project.path, PROJECT_FILE_NAME), original_project_file_path) end
# File lib/kintsugi/apply_change_to_project.rb, line 48 def apply_change_to_component(parent_component, change_name, change) return if change_name == "displayName" attribute_name = attribute_name_from_change_name(change_name) if simple_attribute?(parent_component, attribute_name) apply_change_to_simple_attribute(parent_component, attribute_name, change) return end if change["isa"] component = replace_component_with_new_type(parent_component, attribute_name, change) change = change_for_component_of_new_type(component, change) else component = child_component(parent_component, change_name) if component.nil? add_missing_component_if_valid(parent_component, change_name, change) return end end (change[:removed] || []).each do |removed_change| child = child_component(component, removed_change["displayName"]) next if child.nil? remove_component(child, removed_change) end (change[:added] || []).each do |added_change| is_object_list = component.is_a?(Xcodeproj::Project::ObjectList) add_child_to_component(is_object_list ? parent_component : component, added_change) end subchanges_of_change(change).each do |subchange_name, subchange| apply_change_to_component(component, subchange_name, subchange) end end
# File lib/kintsugi/apply_change_to_project.rb, line 160 def apply_change_to_simple_attribute(component, attribute_name, change) new_attribute_value = simple_attribute_value_with_change(component.send(attribute_name), change) component.send("#{attribute_name}=", new_attribute_value) end
# File lib/kintsugi/apply_change_to_project.rb, line 186 def apply_removal_to_simple_attribute(old_value, change) case change when Array (old_value || []) - change when Hash (old_value || {}).reject do |key, value| if value != change[key] raise "Trying to remove value #{change[key]} of hash with key #{key} but it changed " \ "to #{value}. This is considered a conflict that should be resolved manually." end change.key?(key) end when String if old_value != change && !old_value.nil? raise "Trying to remove value #{change}, but the existing value is #{old_value}. This " \ "is considered a conflict that should be resolved manually." end nil when nil nil else raise "Unsupported change #{change} of type #{change.class}" end end
# File lib/kintsugi/apply_change_to_project.rb, line 94 def attribute_name_from_change_name(change_name) if change_name == "fileEncoding" change_name.to_sym else Xcodeproj::Project::Object::CaseConverter.convert_to_ruby(change_name) end end
# File lib/kintsugi/apply_change_to_project.rb, line 136 def change_for_component_of_new_type(new_component, change) change.select do |subchange_name, _| next false if subchange_name == "isa" attribute_name = attribute_name_from_change_name(subchange_name) new_component.respond_to?(attribute_name) end end
# File lib/kintsugi.rb, line 146 def change_of_conflicting_commit_with_parent(project_file_path) Dir.chdir(File.dirname(project_file_path)) do conflicting_commit_project_file_path = File.join(Dir.mktmpdir, PROJECT_FILE_NAME) `git show :3:./#{PROJECT_FILE_NAME} > #{conflicting_commit_project_file_path}` conflicting_commit_parent_project_file_path = File.join(Dir.mktmpdir, PROJECT_FILE_NAME) `git show :1:./#{PROJECT_FILE_NAME} > #{conflicting_commit_parent_project_file_path}` conflicting_commit_project = Xcodeproj::Project.open( File.dirname(conflicting_commit_project_file_path) ) conflicting_commit_parent_project = Xcodeproj::Project.open(File.dirname(conflicting_commit_parent_project_file_path)) Xcodeproj::Differ.project_diff(conflicting_commit_project, conflicting_commit_parent_project, :added, :removed) end end
# File lib/kintsugi/apply_change_to_project.rb, line 477 def change_with_updated_subproject_uuid(change, subproject_reference_uuid) new_change = change.deep_clone new_change["ProductGroup"]["children"].map do |product_reference_change| product_reference_change["remoteRef"]["containerPortal"] = subproject_reference_uuid product_reference_change end new_change end
# File lib/kintsugi/apply_change_to_project.rb, line 145 def child_component(component, change_name) if component.is_a?(Xcodeproj::Project::ObjectList) component.find { |child| child.display_name == change_name } else attribute_name = attribute_name_from_change_name(change_name) component.send(attribute_name) end end
# File lib/kintsugi/apply_change_to_project.rb, line 125 def copy_attributes_to_new_component(old_component, new_component) # The change won't describe the attributes that haven't changed, therefore the attributes # are copied to the new component. old_component.attributes.each do |attribute| next if %i[isa display_name].include?(attribute.name) || !new_component.respond_to?(attribute.name) new_component.send("#{attribute.name}=", old_component.send(attribute.name)) end end
# File lib/kintsugi.rb, line 112 def copy_project_to_temporary_path_in_directory_with_name(project_file_path, directory_name) temp_directory_name = File.join(Dir.mktmpdir, directory_name) Dir.mkdir(temp_directory_name) temp_project_file_path = File.join(temp_directory_name, PROJECT_FILE_NAME) FileUtils.cp(project_file_path, temp_project_file_path) Xcodeproj::Project.open(File.dirname(temp_project_file_path)) end
# File lib/kintsugi.rb, line 131 def file_has_base_ours_and_theirs_versions?(file_path) Dir.chdir(`git rev-parse --show-toplevel`.strip) do file_has_version_in_stage_numbers?(file_path, [1, 2, 3]) end end
# File lib/kintsugi.rb, line 137 def file_has_version_in_stage_numbers?(file_path, stage_numbers) file_absolute_path = File.absolute_path(file_path) actual_stage_numbers = `git ls-files -u -- #{file_absolute_path}`.split("\n").map do |git_file_status| git_file_status.split[2] end (stage_numbers - actual_stage_numbers.map(&:to_i)).empty? end
# File lib/kintsugi/apply_change_to_project.rb, line 433 def find_containing_project_uuid(project, container_item_proxy_change) if project.objects_by_uuid[container_item_proxy_change["containerPortal"]] return container_item_proxy_change["containerPortal"] end # The `containerPortal` from `container_item_proxy_change` might not be relevant, since when a # project is added its UUID is generated. Instead, existing container item proxies are # searched, until one that has the same remote info as the one in # `container_item_proxy_change` is found. container_item_proxies = project.root_object.project_references.map do |project_ref_and_products| project_ref_and_products[:project_ref].proxy_containers.find do |container_proxy| container_proxy.remote_info == container_item_proxy_change["remoteInfo"] end end.compact if container_item_proxies.length > 1 puts "Debug: Found more than one potential dependency with name " \ "'#{container_item_proxy_change["remoteInfo"]}'. Using the first one." elsif container_item_proxies.empty? puts "Warning: No container portal was found for dependency with name " \ "'#{container_item_proxy_change["remoteInfo"]}'." return end container_item_proxies.first.container_portal end
# File lib/kintsugi/apply_change_to_project.rb, line 559 def find_file(project, file_reference_change) case file_reference_change["isa"] when "PBXFileReference" project.files.find do |file_reference| next file_reference.path == file_reference_change["path"] end when "PBXReferenceProxy" find_reference_proxy(project, file_reference_change["remoteRef"]) else raise "Unsupported file reference change of type #{file_reference["isa"]}." end end
# File lib/kintsugi/apply_change_to_project.rb, line 572 def find_reference_proxy(project, container_item_proxy_change) reference_proxies = project.root_object.project_references.map do |project_ref_and_products| project_ref_and_products[:product_group].children.find do |product| product.remote_ref.remote_global_id_string == container_item_proxy_change["remoteGlobalIDString"] && product.remote_ref.remote_info == container_item_proxy_change["remoteInfo"] end end.compact if reference_proxies.length > 1 puts "Debug: Found more than one matching reference proxy with name " \ "'#{container_item_proxy_change["remoteInfo"]}'. Using the first one." elsif reference_proxies.empty? puts "Warning: No reference proxy was found for name " \ "'#{container_item_proxy_change["remoteInfo"]}'." return end reference_proxies.first end
# File lib/kintsugi/apply_change_to_project.rb, line 413 def find_target(project, display_name) project.targets.find { |target| target.display_name == display_name } end
# File lib/kintsugi/apply_change_to_project.rb, line 393 def find_variant_group(project, display_name) project.objects.find do |object| object.isa == "PBXVariantGroup" && object.display_name == display_name end end
# File lib/kintsugi.rb, line 120 def open_project_of_current_commit_in_temporary_directory(project_file_path) project_directory_name = File.basename(File.dirname(project_file_path)) temp_directory_name = File.join(Dir.mktmpdir, project_directory_name) Dir.mkdir(temp_directory_name) temp_project_file_path = File.join(temp_directory_name, PROJECT_FILE_NAME) Dir.chdir(File.dirname(project_file_path)) do `git show HEAD:./project.pbxproj > #{temp_project_file_path}` end Xcodeproj::Project.open(File.dirname(temp_project_file_path)) end
# File lib/kintsugi/apply_change_to_project.rb, line 248 def remove_build_files_of_file_reference(file_reference, change) # Since the build file's display name depends on the file reference, removing the file # reference before removing it will change the build file's display name which will not be # detected when trying to remove the build file. Therefore, the build files that depend on # the file reference are removed prior to removing the file reference. file_reference.build_files.each do |build_file| build_file.referrers.each do |referrer| referrer.remove_build_file(build_file) end end end
# File lib/kintsugi/apply_change_to_project.rb, line 235 def remove_component(component, change) if component.to_tree_hash != change raise "Trying to remove an object that changed since then. This is considered a conflict " \ "that should be resolved manually. Name of the object is: '#{component.display_name}'" end if change["isa"] == "PBXFileReference" remove_build_files_of_file_reference(component, change) end component.remove_from_project end
# File lib/kintsugi/apply_change_to_project.rb, line 112 def replace_component_with_new_type(parent_component, name_in_parent_component, change) old_component = parent_component.send(name_in_parent_component) new_component = parent_component.project.new( Module.const_get("Xcodeproj::Project::#{change["isa"][:added]}") ) copy_attributes_to_new_component(old_component, new_component) parent_component.send("#{name_in_parent_component}=", new_component) new_component end
# File lib/kintsugi/apply_change_to_project.rb, line 154 def simple_attribute?(component, attribute_name) return false unless component.respond_to?("simple_attributes") component.simple_attributes.any? { |attribute| attribute.name == attribute_name } end
# File lib/kintsugi/apply_change_to_project.rb, line 166 def simple_attribute_value_with_change(old_value, change) new_value = nil if change.key?(:removed) new_value = apply_removal_to_simple_attribute(old_value, change[:removed]) end if change.key?(:added) new_value = apply_addition_to_simple_attribute(old_value, change[:added]) end subchanges_of_change(change).each do |subchange_name, subchange_value| new_value = new_value || old_value || {} new_value[subchange_name] = simple_attribute_value_with_change(old_value[subchange_name], subchange_value) end new_value end
# File lib/kintsugi/apply_change_to_project.rb, line 86 def subchanges_of_change(change) if change.key?(:diff) change[:diff] else change.reject { |change_name, _| %i[added removed].include?(change_name) } end end
# File lib/kintsugi.rb, line 95 def validate_project(project_file_path) unless File.exist?(project_file_path) raise ArgumentError, "File '#{project_file_path}' doesn't exist" end if File.extname(project_file_path) != ".pbxproj" raise ArgumentError, "Wrong file extension, please provide file with extension .pbxproj\"" end Dir.chdir(File.dirname(project_file_path)) do unless file_has_base_ours_and_theirs_versions?(project_file_path) raise ArgumentError, "File '#{project_file_path}' doesn't have conflicts, or a 3-way " \ "merge is not possible." end end end