diff --git a/lib/kintsugi/apply_change_to_project.rb b/lib/kintsugi/apply_change_to_project.rb index 46905d6..0e925b2 100644 --- a/lib/kintsugi/apply_change_to_project.rb +++ b/lib/kintsugi/apply_change_to_project.rb @@ -24,6 +24,12 @@ def to_multi_h module Kintsugi class << self + # Isas of components whose additions and removals are handled by the main group pipeline + # (`apply_group_additions`/`apply_group_removals`). `PBXFileSystemSynchronizedRootGroup` is the + # Xcode 16 "buildable folder" object, which lives in the group tree like a group. + GROUP_PIPELINE_ISAS = + %w[PBXGroup PBXVariantGroup PBXFileSystemSynchronizedRootGroup].freeze + # Applies the change specified by `change` to `project`. # # @param [Xcodeproj::Project] project @@ -45,6 +51,7 @@ def apply_change_to_project(project, change, change_source_project) @change_source_project = change_source_project @ignored_components_group_paths = [] @created_components_group_paths = [] + @pending_exception_references = [] # We iterate over the main group and project references first because they might create file # or project references that are referenced in other parts. @@ -65,6 +72,11 @@ def apply_change_to_project(project, change, change_source_project) change["rootObject"].reject { |key| %w[mainGroup projectReferences].include?(key) }, "") + + # Exception sets reference targets and build phases that may only be created later in the + # change (and groups are linked to their targets only in the rootObject pass above), so their + # references are resolved here, once everything exists. + resolve_pending_exception_references end private @@ -107,7 +119,7 @@ def flatten_change(change, path) def apply_group_additions(project, additions, force_create_containing_group: false) additions.each do |change, path| - next unless %w[PBXGroup PBXVariantGroup].include?(change["isa"]) + next unless GROUP_PIPELINE_ISAS.include?(change["isa"]) group_type = Module.const_get("Xcodeproj::Project::#{change["isa"]}") containing_group = project.group_or_file_at_path(path) @@ -244,7 +256,8 @@ def create_nonexistent_groupable_component(project, path) when Xcodeproj::Project::PBXFileReference apply_file_changes(project, [[component_change, containing_group_path]], [], force_create_containing_group: true) - when Xcodeproj::Project::PBXGroup + when Xcodeproj::Project::PBXGroup, + Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup apply_group_additions(project, [[component_change, containing_group_path]], force_create_containing_group: true) else @@ -256,14 +269,16 @@ def create_nonexistent_groupable_component(project, path) def apply_group_removals(project, removals) removals.sort_by(&:last).reverse.each do |change, path| - next unless %w[PBXGroup PBXVariantGroup].include?(change["isa"]) + next unless GROUP_PIPELINE_ISAS.include?(change["isa"]) group_path = join_path(path, change["displayName"]) # by now we've deleted all of this group's children in the project, so we need to adapt the # change to the expected current state of the group, that is, without any children. + # `PBXFileSystemSynchronizedRootGroup` has no `children` attribute, so we must not inject an + # empty one, otherwise `remove_component`'s tree hash comparison would never match. change_without_children = change.dup - change_without_children["children"] = [] + change_without_children["children"] = [] if change.key?("children") remove_component(project[group_path], change_without_children) end @@ -291,12 +306,7 @@ def apply_change_to_component(parent_component, change_name, change, parent_chan if change[:removed].is_a?(Hash) remove_component(component, change[:removed]) elsif change[:removed].is_a?(Array) - unless component.nil? - (change[:removed]).each do |removed_change| - child = child_component_of_object_list(component, removed_change["displayName"]) - remove_component(child, removed_change) - end - end + remove_children_from_object_list(component, change[:removed]) unless component.nil? elsif !change[:removed].nil? raise MergeError, "Unsupported removed change type for #{change[:removed]}" end @@ -323,6 +333,22 @@ def apply_change_to_component(parent_component, change_name, change, parent_chan end end + def remove_children_from_object_list(object_list, removed_changes) + removed_changes.each do |removed_change| + child = child_component_of_object_list(object_list, removed_change["displayName"]) + if removed_change["isa"] == "PBXFileSystemSynchronizedRootGroup" + # A target references the buildable folder object; unlinking it must detach the reference, + # not unconditionally delete the shared object. `ObjectList#delete` is reference-counted: + # it removes the object only when this was its last referrer, so a folder still referenced + # by the main group or another target survives. (Full deletion of the folder itself is + # driven separately by its main group entry, via `apply_group_removals`.) + object_list.delete(child) unless child.nil? + else + remove_component(child, removed_change) + end + end + end + def resolve_nonexistent_component(parent_component, change_path) source_project_component = component_at_path(@change_source_project, change_path) group_path = group_path_of_group_based_component(source_project_component) @@ -353,6 +379,10 @@ def group_path_of_group_based_component(component) elsif component.is_a?(Xcodeproj::Project::PBXFileReference) || component.is_a?(Xcodeproj::Project::PBXGroup) component.hierarchy_path.delete_prefix("/") + elsif component.is_a?(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup) + # A synchronized root group is not a `PBXGroup`, so it has no `hierarchy_path` instance + # method; the groupable helper computes the same path from its parent chain. + Xcodeproj::Project::Object::GroupableHelper.hierarchy_path(component).delete_prefix("/") end end @@ -652,6 +682,13 @@ def add_child_to_component(component, change, change_path) add_file_reference(component, change, change_path) when "PBXGroup" add_group(component, change, change_path) + when "PBXFileSystemSynchronizedRootGroup" + add_file_system_synchronized_root_group(component, change, change_path) + when "PBXFileSystemSynchronizedBuildFileExceptionSet" + add_file_system_synchronized_build_file_exception_set(component, change, change_path) + when "PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet" + add_file_system_synchronized_group_build_phase_membership_exception_set(component, change, + change_path) when "PBXContainerItemProxy" add_container_item_proxy(component, change, change_path) when "PBXTargetDependency" @@ -768,6 +805,239 @@ def add_variant_group(containing_component, change, change_path) end end + # Known limitation: buildable folders have no stable identity in a project diff (they're + # compared by value), so relocating a folder between parent groups while it stays linked to a + # target reads as remove-from-A + add-to-B. The old object (and its target link) is removed, and + # the new object is created but not re-linked to the target, since the target's own change is + # empty. Such a merge can drop the folder's target membership; re-verify after relocating. + def add_file_system_synchronized_root_group(containing_component, change, change_path) + case containing_component + when Xcodeproj::Project::PBXNativeTarget + # The group was already created in the group tree by the main group pass. Resolve it by its + # position in the tree so we reuse the exact same object, even when another buildable folder + # shares its name in a different parent group. + group = resolve_file_system_synchronized_root_group(containing_component, change) + if group.nil? + raise MergeError, "No file system synchronized root group matching #{change} was " \ + "found in the group tree. Change path: #{change_path}" + end + + # Dedup by object identity, not display name: one target can legitimately link several + # same-named folders from different parent groups, each a distinct object. + already_linked = containing_component.file_system_synchronized_groups.any? do |linked| + linked.uuid == group.uuid + end + return if !Settings.allow_duplicates && already_linked + + containing_component.file_system_synchronized_groups << group + else + raise MergeError, "Trying to add file system synchronized root group to an unsupported " \ + "component type #{containing_component.isa}. Change is: #{change}" + end + end + + def resolve_file_system_synchronized_root_group(target, change) + candidates = target.project.objects.select do |object| + object.isa == "PBXFileSystemSynchronizedRootGroup" && + object.display_name == change["displayName"] + end + return candidates.first if candidates.length <= 1 + + # More than one buildable folder shares this name (in different parent groups). Match against + # the folders the change's target links in the source project, comparing hierarchy paths as + # strings. A folder's own `path`/`display_name` may itself contain "/", so this must not be + # split into segments, so path-walking helpers like `group_or_file_at_path` can't be used. + source_hierarchies = source_synchronized_root_group_hierarchies(target, change) + source_matching = candidates.select do |candidate| + source_hierarchies.include?(synchronized_root_group_hierarchy_path(candidate)) + end + return candidates.first if source_matching.empty? + + # Prefer a folder not already linked to this target, so a target linking two same-named + # folders resolves each addition to a distinct object rather than dropping the second. + linked_uuids = target.file_system_synchronized_groups.map(&:uuid) + source_matching.find { |candidate| !linked_uuids.include?(candidate.uuid) } || + source_matching.first + end + + # Hierarchy paths (in source order) of the buildable folders the change's target links that + # match `change`. A target may link several same-named folders from different parents; their + # identity lets us map each addition to the right destination object. + def source_synchronized_root_group_hierarchies(target, change) + source_target = find_target(@change_source_project, target.display_name) + (source_target&.file_system_synchronized_groups || []) + .select { |group| group.to_tree_hash == change } + .map { |group| synchronized_root_group_hierarchy_path(group) } + .compact + end + + # Groupable helper raises a `RuntimeError` consistency error (rather than returning nil) for an + # object with no parent group; treat that as "no hierarchy" so a stray unparented candidate + # can't abort the merge. Narrowly rescued so unrelated errors still surface. + def synchronized_root_group_hierarchy_path(group) + Xcodeproj::Project::Object::GroupableHelper.hierarchy_path(group) + rescue RuntimeError + nil + end + + def add_file_system_synchronized_build_file_exception_set(containing_component, change, + change_path) + unless containing_component.is_a?(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup) + raise MergeError, "Trying to add file system synchronized build file exception set to an " \ + "unsupported component type #{containing_component.isa}. Change is: " \ + "#{change}" + end + return if exception_set_already_exists?(containing_component, change) + + exception_set = containing_component.project.new( + Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet + ) + containing_component.exceptions << exception_set + add_attributes_to_component(exception_set, change, change_path, ignore_keys: ["target"]) + resolve_or_defer_exception_reference(exception_set, :target, change["target"]) + end + + def add_file_system_synchronized_group_build_phase_membership_exception_set( + containing_component, change, change_path + ) + unless containing_component.is_a?(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup) + raise MergeError, "Trying to add file system synchronized group build phase membership " \ + "exception set to an unsupported component type " \ + "#{containing_component.isa}. Change is: #{change}" + end + return if exception_set_already_exists?(containing_component, change) + + exception_set = containing_component.project.new( + Xcodeproj::Project::PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet + ) + containing_component.exceptions << exception_set + add_attributes_to_component(exception_set, change, change_path, ignore_keys: ["buildPhase"]) + resolve_or_defer_exception_reference(exception_set, :build_phase, change["buildPhase"]) + end + + # `true` if an equivalent exception set already exists on `group`. This is intentionally NOT + # gated on `Settings.allow_duplicates`: a synchronized root group is referenced from both the + # main group and its target(s), so the same exception addition is visited through several graph + # paths, and two exception sets identical in target/build phase + membership are meaningless. + def exception_set_already_exists?(group, change) + group.exceptions.any? { |exception_set| exception_set_matches_change?(exception_set, change) } + end + + def exception_set_matches_change?(exception_set, change) + signature = exception_set.to_tree_hash + pending = @pending_exception_references.find { |set, _, _| set.equal?(exception_set) } + unless pending.nil? + _, kind, reference = pending + signature = signature.merge(exception_reference_key(kind) => reference) + end + # `displayName` is derived from the (possibly still-unresolved) reference and the folder name, + # so it carries no information the reference key and simple attributes don't already carry; + # excluding it avoids a spurious mismatch when the reference is still pending (nil). + signature.reject { |key, _| key == "displayName" } == + change.reject { |key, _| key == "displayName" } + end + + def exception_reference_key(kind) + kind == :target ? "target" : "buildPhase" + end + + # Resolves the target/build phase an exception set references. The target may be created, and + # the group linked to its targets, only later, so unresolvable references are recorded and + # retried by `resolve_pending_exception_references` after the whole change is applied. Build + # phase resolution always defers: it needs the group already linked to a target. + def resolve_or_defer_exception_reference(exception_set, kind, reference) + # Only the target reference is resolved eagerly (it just needs the target to exist). The build + # phase reference is always deferred, because the phase it points to might itself be created + # later in the same change (e.g. a target and its build phases added together). + if kind == :target + resolved = resolve_exception_reference(exception_set, kind, reference) + unless resolved.nil? + assign_exception_reference(exception_set, kind, resolved) + return + end + end + + @pending_exception_references << [exception_set, kind, reference] + end + + def resolve_pending_exception_references + @pending_exception_references.each do |exception_set, kind, reference| + resolved = resolve_exception_reference(exception_set, kind, reference) + if resolved.nil? + puts "Warning: Couldn't resolve #{kind} reference #{reference.inspect} for exception " \ + "set '#{exception_set.display_name}'." + next + end + + assign_exception_reference(exception_set, kind, resolved) + end + @pending_exception_references = [] + end + + def resolve_exception_reference(exception_set, kind, reference) + case kind + when :target + find_target(exception_set.project, reference) + when :build_phase + find_synchronized_group_build_phase(exception_set, reference) + end + end + + def assign_exception_reference(exception_set, kind, resolved) + case kind + when :target then exception_set.target = resolved + when :build_phase then exception_set.build_phase = resolved + end + end + + # Finds the build phase an exception set references. The reference carries its owning target, + # so we resolve that target by name (unique) then its build phase by name. This disambiguates + # same-named phases (e.g. "Sources") when the group is shared by several targets. Returns nil + # when the target isn't created yet, so the caller can defer. + def find_synchronized_group_build_phase(exception_set, reference) + project = exception_set.project + target_name = reference.is_a?(Hash) ? reference["target"] : nil + phase_name = reference.is_a?(Hash) ? reference["name"] : reference + + candidate_targets = + if target_name.nil? + synchronized_group_referencing_targets(exception_set) + else + target = find_target(project, target_name) + return nil if target.nil? + + [target] + end + + matching_build_phases = candidate_targets.flat_map(&:build_phases).select do |build_phase| + build_phase.display_name == phase_name + end + if matching_build_phases.length > 1 + puts "Debug: Multiple build phases named '#{phase_name}'. Using the first one." + end + matching_build_phases.first + end + + # The targets that reference the exception set's owning synchronized root group. Used as a + # fallback when a build phase reference doesn't carry its owning target. The groupable helper + # raises `RuntimeError` for an unparented set (e.g. its group was removed earlier in the same + # change); fall back to all native targets rather than aborting the merge. + def synchronized_group_referencing_targets(exception_set) + project = exception_set.project + group = + begin + Xcodeproj::Project::Object::GroupableHelper.parent(exception_set) + rescue RuntimeError + nil + end + return project.native_targets if group.nil? + + referencing_targets = project.native_targets.select do |target| + target.file_system_synchronized_groups.any? { |linked| linked.uuid == group.uuid } + end + referencing_targets.empty? ? project.native_targets : referencing_targets + end + def add_build_rule(target, change, change_path) build_rule = target.project.new(Xcodeproj::Project::PBXBuildRule) target.build_rules << build_rule diff --git a/lib/kintsugi/xcodeproj_extensions.rb b/lib/kintsugi/xcodeproj_extensions.rb index 5197759..bdc1ee3 100644 --- a/lib/kintsugi/xcodeproj_extensions.rb +++ b/lib/kintsugi/xcodeproj_extensions.rb @@ -126,6 +126,69 @@ def to_tree_hash end end + # Modifies `PBXFileSystemSynchronizedBuildFileExceptionSet`'s `to_tree_hash` to serialize its + # `target` as a reference (by display name) instead of recursing into it. Without this, the + # target expands into a hash that contains the synchronized root group owning this exception + # set, which owns this exception set, causing infinite recursion. + class PBXFileSystemSynchronizedBuildFileExceptionSet + # xcodeproj 1.27.0's `display_name` interpolates `#{target.name}`; guard against a nil target + # (which occurs transiently while the target reference is resolved) to avoid `NoMethodError` + # during serialization or tree hashing. `GroupableHelper.parent` raises (and re-interpolates + # `display_name`, recursing to a stack overflow) when the set has no referrers, so only call + # it when a parent actually exists. + def display_name + folder_name = referrers.empty? ? nil : GroupableHelper.parent(self)&.display_name + "Exceptions for \"#{folder_name}\" folder in \"#{target&.name}\" target" + end + + def to_tree_hash + hash = { 'displayName' => display_name, 'isa' => isa } + self.class.simple_attributes.each do |attribute| + value = attribute.get_value(self) + hash[attribute.plist_name] = value unless value.nil? + end + hash['target'] = target.display_name if target + hash + end + end + + # Same fix as `PBXFileSystemSynchronizedBuildFileExceptionSet`, for the build phase membership + # variant, whose recursing reference is `build_phase`. + class PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet + # xcodeproj 1.27.0's `display_name` calls `build_phase.name`, which build phases don't + # implement, raising `NoMethodError` on any serialization or tree hash of this object. Use + # the build phase's `display_name` (e.g. "Sources") instead. Also guard the parent lookup, + # which otherwise raises and recurses to a stack overflow when the set has no referrers. + def display_name + folder_name = referrers.empty? ? nil : GroupableHelper.parent(self)&.display_name + "Exceptions for \"#{folder_name}\" folder in \"#{build_phase&.display_name}\" build phase" + end + + def to_tree_hash + hash = { 'displayName' => display_name, 'isa' => isa } + self.class.simple_attributes.each do |attribute| + value = attribute.get_value(self) + hash[attribute.plist_name] = value unless value.nil? + end + hash['buildPhase'] = build_phase_reference if build_phase + hash + end + + # Serializes `build_phase` as a reference that also carries its owning target, so it can be + # resolved unambiguously even when the owning group is shared by multiple targets that each + # have a build phase with the same name (e.g. "Sources"). + def build_phase_reference + reference = { 'name' => build_phase.display_name } + # Match by UUID: xcodeproj compares build phases by value, so `include?` would match a + # same-named empty phase on an unrelated target. + owning_target = build_phase.project.native_targets.find do |target| + target.build_phases.any? { |phase| phase.uuid == build_phase.uuid } + end + reference['target'] = owning_target.display_name unless owning_target.nil? + reference + end + end + # By default, for this type, the `display_name` is used when calling `ascii_plist_annotation` (which is used # to serialize the project to disk). In the case where the `display_name` contains a "plugin:" prefix, which # means that the package is a plugin, the prefix is ommitted so just the package name is used. diff --git a/spec/kintsugi_apply_change_to_project_spec.rb b/spec/kintsugi_apply_change_to_project_spec.rb index ec2d548..a387acf 100644 --- a/spec/kintsugi_apply_change_to_project_spec.rb +++ b/spec/kintsugi_apply_change_to_project_spec.rb @@ -2148,6 +2148,593 @@ end end + describe "file system synchronized groups" do + before do + base_project.new_target("com.apple.product-type.library.static", "foo", :ios) + end + + def add_synchronized_root_group(project, path, source_tree: "") + group = project.new(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup) + group.source_tree = source_tree + group.path = path + project.main_group.children << group + group + end + + it "adds a file system synchronized root group to the main group" do + theirs_project = create_copy_of_project(base_project, "theirs") + add_synchronized_root_group(theirs_project, "SyncedSources") + + changes_to_apply = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(base_project, changes_to_apply, theirs_project) + + expect(base_project).to be_equivalent_to_project(theirs_project) + end + + it "adds a file system synchronized root group referenced by a target" do + theirs_project = create_copy_of_project(base_project, "theirs") + group = add_synchronized_root_group(theirs_project, "SyncedSources") + theirs_project.targets[0].file_system_synchronized_groups << group + + changes_to_apply = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(base_project, changes_to_apply, theirs_project) + + expect(base_project).to be_equivalent_to_project(theirs_project) + + # `be_equivalent_to_project` compares tree hashes (attributes only), so it can't detect a + # duplicated object. Assert that the target reuses the exact same object that was created in + # the group tree, rather than a second root group with the same attributes. + synchronized_groups = base_project.objects.select do |object| + object.isa == "PBXFileSystemSynchronizedRootGroup" + end + expect(synchronized_groups.count).to eq(1) + expect(base_project.targets[0].file_system_synchronized_groups.first) + .to equal(synchronized_groups.first) + end + + it "adds a synchronized root group with explicit file types and folders" do + theirs_project = create_copy_of_project(base_project, "theirs") + group = add_synchronized_root_group(theirs_project, "SyncedSources") + group.explicit_file_types = {"*.md" => "text"} + group.explicit_folders = ["Fixtures"] + theirs_project.targets[0].file_system_synchronized_groups << group + + changes_to_apply = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(base_project, changes_to_apply, theirs_project) + + expect(base_project).to be_equivalent_to_project(theirs_project) + end + + it "adds a synchronized root group with build file exceptions" do + theirs_project = create_copy_of_project(base_project, "theirs") + group = add_synchronized_root_group(theirs_project, "SyncedSources") + theirs_project.targets[0].file_system_synchronized_groups << group + exception_set = + theirs_project.new(Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet) + exception_set.target = theirs_project.targets[0] + exception_set.membership_exceptions = ["Excluded.swift"] + group.exceptions << exception_set + + changes_to_apply = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(base_project, changes_to_apply, theirs_project) + + expect(base_project).to be_equivalent_to_project(theirs_project) + resolved = base_project.objects.find do |object| + object.isa == "PBXFileSystemSynchronizedBuildFileExceptionSet" + end + expect(resolved.target).to equal(base_project.targets[0]) + end + + it "adds a synchronized root group with build phase membership exceptions" do + theirs_project = create_copy_of_project(base_project, "theirs") + group = add_synchronized_root_group(theirs_project, "SyncedSources") + theirs_project.targets[0].file_system_synchronized_groups << group + klass = Xcodeproj::Project::PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet + exception_set = theirs_project.new(klass) + exception_set.build_phase = theirs_project.targets[0].source_build_phase + exception_set.membership_exceptions = ["OnlyInSources.swift"] + group.exceptions << exception_set + + changes_to_apply = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(base_project, changes_to_apply, theirs_project) + + expect(base_project).to be_equivalent_to_project(theirs_project) + end + + it "removes a file system synchronized root group" do + group = add_synchronized_root_group(base_project, "SyncedSources") + base_project.targets[0].file_system_synchronized_groups << group + + theirs_project = create_copy_of_project(base_project, "theirs") + theirs_project.main_group.children + .find { |child| child.display_name == "SyncedSources" } + .remove_from_project + + changes_to_apply = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(base_project, changes_to_apply, theirs_project) + + expect(base_project).to be_equivalent_to_project(theirs_project) + expect(base_project.objects.count { |o| o.isa == "PBXFileSystemSynchronizedRootGroup" }) + .to eq(0) + end + + it "unlinks a folder from one target without deleting it for the others" do + base_project.new_target("com.apple.product-type.library.static", "bar", :ios) + group = add_synchronized_root_group(base_project, "Shared") + base_project.targets.each { |target| target.file_system_synchronized_groups << group } + + theirs_project = create_copy_of_project(base_project, "theirs") + foo = theirs_project.targets.find { |target| target.display_name == "foo" } + foo.file_system_synchronized_groups.delete(foo.file_system_synchronized_groups.first) + + changes = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(base_project, changes, theirs_project) + + expect(base_project).to be_equivalent_to_project(theirs_project) + # The shared folder must survive: only foo's link is removed; bar keeps it and it stays in + # the main group (removing a target reference must not delete the shared object). + expect(base_project.objects.count { |o| o.isa == "PBXFileSystemSynchronizedRootGroup" }) + .to eq(1) + expect(base_project.targets.find { |t| t.display_name == "foo" } + .file_system_synchronized_groups).to be_empty + expect(base_project.targets.find { |t| t.display_name == "bar" } + .file_system_synchronized_groups.count).to eq(1) + end + + it "keeps a folder shared by three targets when one unlinks it" do + base_project.new_target("com.apple.product-type.library.static", "bar", :ios) + base_project.new_target("com.apple.product-type.library.static", "baz", :ios) + group = add_synchronized_root_group(base_project, "Shared") + base_project.targets.each { |target| target.file_system_synchronized_groups << group } + + theirs_project = create_copy_of_project(base_project, "theirs") + foo = theirs_project.targets.find { |target| target.display_name == "foo" } + foo.file_system_synchronized_groups.delete(foo.file_system_synchronized_groups.first) + + changes = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(base_project, changes, theirs_project) + + expect(base_project).to be_equivalent_to_project(theirs_project) + expect(base_project.objects.count { |o| o.isa == "PBXFileSystemSynchronizedRootGroup" }) + .to eq(1) + %w[bar baz].each do |name| + expect(base_project.targets.find { |t| t.display_name == name } + .file_system_synchronized_groups.count).to eq(1) + end + end + + it "removes a folder shared by two targets and unlinks both in one change" do + base_project.new_target("com.apple.product-type.library.static", "bar", :ios) + group = add_synchronized_root_group(base_project, "Shared") + base_project.targets.each { |target| target.file_system_synchronized_groups << group } + + theirs_project = create_copy_of_project(base_project, "theirs") + theirs_project.main_group.children + .find { |child| child.display_name == "Shared" } + .remove_from_project + + changes = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(base_project, changes, theirs_project) + + expect(base_project).to be_equivalent_to_project(theirs_project) + expect(base_project.objects.count { |o| o.isa == "PBXFileSystemSynchronizedRootGroup" }) + .to eq(0) + base_project.targets.each do |target| + expect(target.file_system_synchronized_groups).to be_empty + end + end + + it "resolves a same-named folder when another candidate is not in the group tree" do + # A buildable folder linked by two targets but absent from the main group has no parent, so + # its hierarchy_path raises; resolution must tolerate it rather than aborting the merge. + base_project.new_target("com.apple.product-type.library.static", "bar", :ios) + groupless = base_project.new(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup) + groupless.source_tree = "" + groupless.path = "Shared" + base_project.targets.each { |target| target.file_system_synchronized_groups << groupless } + + theirs_project = create_copy_of_project(base_project, "theirs") + feature = theirs_project.main_group.new_group("FeatureA") + real_group = theirs_project.new(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup) + real_group.source_tree = "" + real_group.path = "Shared" + feature << real_group + theirs_project.targets.find { |t| t.display_name == "foo" } + .file_system_synchronized_groups << real_group + + changes = get_diff(theirs_project, base_project) + expect { + described_class.apply_change_to_project(base_project, changes, theirs_project) + }.not_to raise_error + + # The new FeatureA/Shared is linked in addition to the pre-existing group-tree-less folder. + expect(base_project.targets.find { |t| t.display_name == "foo" } + .file_system_synchronized_groups.count).to eq(2) + end + + it "avoids adding a synchronized root group that already exists" do + existing_group = add_synchronized_root_group(base_project, "SyncedSources") + base_project.targets[0].file_system_synchronized_groups << existing_group + + theirs_project = create_copy_of_project(base_project, "theirs") + theirs_group = add_synchronized_root_group(theirs_project, "SyncedSources") + theirs_project.targets[0].file_system_synchronized_groups << theirs_group + + changes_to_apply = get_diff(theirs_project, base_project) + other_project = create_copy_of_project(base_project, "other") + described_class.apply_change_to_project(other_project, changes_to_apply, theirs_project) + + expect(other_project).to be_equivalent_to_project(base_project) + end + + it "resolves an exception target added in the same change" do + theirs_project = create_copy_of_project(base_project, "theirs") + target = theirs_project.new_target("com.apple.product-type.library.static", "bar", :ios) + group = add_synchronized_root_group(theirs_project, "SyncedSources") + target.file_system_synchronized_groups << group + + klass = Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet + exception = theirs_project.new(klass) + exception.target = target + exception.membership_exceptions = ["Excluded.swift"] + group.exceptions << exception + + changes = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(base_project, changes, theirs_project) + + resolved = base_project.objects.find { |object| object.is_a?(klass) } + expected_target = base_project.targets.find { |item| item.name == "bar" } + expect(resolved.target&.uuid).to eq(expected_target.uuid) + end + + it "resolves a build-phase exception against the correct target" do + base_project.new_target("com.apple.product-type.library.static", "bar", :ios) + theirs_project = create_copy_of_project(base_project, "theirs") + target = theirs_project.targets.find { |item| item.name == "bar" } + group = add_synchronized_root_group(theirs_project, "SyncedSources") + target.file_system_synchronized_groups << group + + klass = Xcodeproj::Project::PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet + exception = theirs_project.new(klass) + exception.build_phase = target.source_build_phase + exception.membership_exceptions = ["OnlyInSources.swift"] + group.exceptions << exception + + changes = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(base_project, changes, theirs_project) + + resolved = base_project.objects.find { |object| object.is_a?(klass) } + expected_phase = base_project.targets.find { |item| item.name == "bar" }.source_build_phase + expect(resolved.build_phase.uuid).to eq(expected_phase.uuid) + end + + it "resolves a build-phase exception when the folder is shared by multiple targets" do + base_project.new_target("com.apple.product-type.library.static", "bar", :ios) + theirs_project = create_copy_of_project(base_project, "theirs") + group = add_synchronized_root_group(theirs_project, "Shared") + theirs_project.targets.find { |item| item.display_name == "foo" } + .file_system_synchronized_groups << group + bar = theirs_project.targets.find { |item| item.display_name == "bar" } + bar.file_system_synchronized_groups << group + + klass = Xcodeproj::Project::PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet + exception = theirs_project.new(klass) + # Reference the SECOND target's Sources phase. "foo" (added first) also references the group + # and has its own "Sources" phase, so a resolver scoped only to the referencing targets would + # pick foo's phase instead of bar's. + exception.build_phase = bar.source_build_phase + exception.membership_exceptions = ["OnlyInBarSources.swift"] + group.exceptions << exception + + changes = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(base_project, changes, theirs_project) + + expect(base_project).to be_equivalent_to_project(theirs_project) + resolved = base_project.objects.find { |object| object.is_a?(klass) } + expected_phase = base_project.targets.find { |item| item.name == "bar" }.source_build_phase + expect(resolved.build_phase.uuid).to eq(expected_phase.uuid) + end + + it "attaches the correct folder when two buildable folders share a name" do + base_project.new_target("com.apple.product-type.library.static", "bar", :ios) + theirs_project = create_copy_of_project(base_project, "theirs") + + feature_a = theirs_project.main_group.new_group("FeatureA") + feature_b = theirs_project.main_group.new_group("FeatureB") + group_a = theirs_project.new(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup) + group_a.source_tree = "" + group_a.path = "Shared" + feature_a << group_a + group_b = theirs_project.new(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup) + group_b.source_tree = "" + group_b.path = "Shared" + feature_b << group_b + theirs_project.targets.find { |item| item.display_name == "foo" } + .file_system_synchronized_groups << group_a + theirs_project.targets.find { |item| item.display_name == "bar" } + .file_system_synchronized_groups << group_b + + changes = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(base_project, changes, theirs_project) + + expect(base_project).to be_equivalent_to_project(theirs_project) + + helper = Xcodeproj::Project::Object::GroupableHelper + foo_group = base_project.targets.find { |item| item.display_name == "foo" } + .file_system_synchronized_groups.first + bar_group = base_project.targets.find { |item| item.display_name == "bar" } + .file_system_synchronized_groups.first + expect(helper.hierarchy_path(foo_group)).to eq("/FeatureA/Shared") + expect(helper.hierarchy_path(bar_group)).to eq("/FeatureB/Shared") + end + + it "does not duplicate an exception set added on both sides" do + group = add_synchronized_root_group(base_project, "Models") + base_project.targets[0].file_system_synchronized_groups << group + common_base = create_copy_of_project(base_project, "base") + + add_build_file_exception = lambda do |project| + synchronized_group = project.objects.find do |object| + object.isa == "PBXFileSystemSynchronizedRootGroup" + end + exception = project.new(Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet) + exception.target = project.targets.find { |target| target.display_name == "foo" } + exception.membership_exceptions = ["Excluded.swift"] + synchronized_group.exceptions << exception + end + + theirs_project = create_copy_of_project(base_project, "theirs") + add_build_file_exception.call(theirs_project) + add_build_file_exception.call(base_project) + + changes = get_diff(theirs_project, common_base) + described_class.apply_change_to_project(base_project, changes, theirs_project) + + exception_count = base_project.objects.count do |object| + object.isa == "PBXFileSystemSynchronizedBuildFileExceptionSet" + end + expect(exception_count).to eq(1) + end + + it "does not duplicate a deferred exception added to an existing buildable folder" do + group = add_synchronized_root_group(base_project, "Models") + base_project.targets[0].file_system_synchronized_groups << group + common_base = create_copy_of_project(base_project, "base") + + klass = Xcodeproj::Project::PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet + theirs_project = create_copy_of_project(base_project, "theirs") + synchronized_group = theirs_project.objects.find do |object| + object.isa == "PBXFileSystemSynchronizedRootGroup" + end + exception = theirs_project.new(klass) + exception.build_phase = theirs_project.targets[0].source_build_phase + exception.membership_exceptions = ["Excluded.swift"] + synchronized_group.exceptions << exception + + changes = get_diff(theirs_project, common_base) + described_class.apply_change_to_project(base_project, changes, theirs_project) + + # The build-phase reference is resolved lazily, so the exception travels both the main-group + # and the target graph paths with a still-unresolved reference; dedup must recognize it. + expect(base_project.objects.count { |object| object.is_a?(klass) }).to eq(1) + expect(base_project).to be_equivalent_to_project(theirs_project) + end + + it "attaches the correct slashed-path folder when two share a name" do + base_project.new_target("com.apple.product-type.library.static", "bar", :ios) + theirs_project = create_copy_of_project(base_project, "theirs") + + klass = Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup + feature_a = theirs_project.main_group.new_group("FeatureA") + feature_b = theirs_project.main_group.new_group("FeatureB") + group_a = theirs_project.new(klass) + group_a.source_tree = "" + group_a.path = "Sub/Shared" + feature_a << group_a + group_b = theirs_project.new(klass) + group_b.source_tree = "" + group_b.path = "Sub/Shared" + feature_b << group_b + theirs_project.targets.find { |item| item.display_name == "foo" } + .file_system_synchronized_groups << group_a + theirs_project.targets.find { |item| item.display_name == "bar" } + .file_system_synchronized_groups << group_b + + changes = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(base_project, changes, theirs_project) + + helper = Xcodeproj::Project::Object::GroupableHelper + foo_group = base_project.targets.find { |item| item.display_name == "foo" } + .file_system_synchronized_groups.first + bar_group = base_project.targets.find { |item| item.display_name == "bar" } + .file_system_synchronized_groups.first + expect(helper.hierarchy_path(foo_group)).to eq("/FeatureA/Sub/Shared") + expect(helper.hierarchy_path(bar_group)).to eq("/FeatureB/Sub/Shared") + end + + it "serializes a build file exception set with an unset target without crashing" do + group = add_synchronized_root_group(base_project, "Models") + exception = + base_project.new(Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet) + group.exceptions << exception + + expect { exception.to_tree_hash }.not_to raise_error + expect(exception.to_tree_hash["displayName"]) + .to eq("Exceptions for \"Models\" folder in \"\" target") + end + + it "serializes an exception set with no parent group without infinite recursion" do + exception = + base_project.new(Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet) + + expect { exception.to_tree_hash }.not_to raise_error + end + + it "serializes a parent-less build-phase exception set without infinite recursion" do + klass = Xcodeproj::Project::PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet + exception = base_project.new(klass) + + expect { exception.to_tree_hash }.not_to raise_error + expect(exception.to_tree_hash["displayName"]) + .to eq("Exceptions for \"\" folder in \"\" build phase") + end + + it "links both folders when one target references two folders sharing a name" do + theirs_project = create_copy_of_project(base_project, "theirs") + target = theirs_project.targets.find { |item| item.display_name == "foo" } + + klass = Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup + %w[FeatureA FeatureB].each do |feature| + parent = theirs_project.main_group.new_group(feature) + group = theirs_project.new(klass) + group.source_tree = "" + group.path = "Shared" + parent << group + target.file_system_synchronized_groups << group + end + + changes = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(base_project, changes, theirs_project) + + helper = Xcodeproj::Project::Object::GroupableHelper + linked = base_project.targets.find { |item| item.display_name == "foo" } + .file_system_synchronized_groups + expect(linked.map { |group| helper.hierarchy_path(group) }) + .to contain_exactly("/FeatureA/Shared", "/FeatureB/Shared") + end + + it "keeps exception sets that differ only by target" do + base_project.new_target("com.apple.product-type.library.static", "bar", :ios) + theirs_project = create_copy_of_project(base_project, "theirs") + group = add_synchronized_root_group(theirs_project, "Models") + theirs_project.targets.each { |target| target.file_system_synchronized_groups << group } + + klass = Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet + %w[foo bar].each do |target_name| + exception = theirs_project.new(klass) + exception.target = theirs_project.targets.find { |t| t.display_name == target_name } + exception.membership_exceptions = ["Excluded.swift"] + group.exceptions << exception + end + + changes = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(base_project, changes, theirs_project) + + expect(base_project.objects.count { |object| object.is_a?(klass) }).to eq(2) + expect(base_project).to be_equivalent_to_project(theirs_project) + end + + it "keeps exception sets that differ only by membership" do + theirs_project = create_copy_of_project(base_project, "theirs") + group = add_synchronized_root_group(theirs_project, "Models") + theirs_project.targets[0].file_system_synchronized_groups << group + + klass = Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet + %w[A B].each do |suffix| + exception = theirs_project.new(klass) + exception.target = theirs_project.targets[0] + exception.membership_exceptions = ["Excluded#{suffix}.swift"] + group.exceptions << exception + end + + changes = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(base_project, changes, theirs_project) + + expect(base_project.objects.count { |object| object.is_a?(klass) }).to eq(2) + expect(base_project).to be_equivalent_to_project(theirs_project) + end + + it "recreates a synchronized root group modified on theirs and deleted on ours" do + allow(Kintsugi::ConflictResolver).to receive(:create_nonexistent_component_when_changing_it?) + .and_return(true) + + group = add_synchronized_root_group(base_project, "Models") + base_project.targets[0].file_system_synchronized_groups << group + common_base = create_copy_of_project(base_project, "base") + + theirs_project = create_copy_of_project(base_project, "theirs") + theirs_project.objects.find { |object| object.isa == "PBXFileSystemSynchronizedRootGroup" } + .explicit_folders = ["Generated"] + + base_project.objects.find { |object| object.isa == "PBXFileSystemSynchronizedRootGroup" } + .remove_from_project + + changes = get_diff(theirs_project, common_base) + expect { + described_class.apply_change_to_project(base_project, changes, theirs_project) + }.not_to raise_error + + recreated = + base_project.objects.find { |object| object.isa == "PBXFileSystemSynchronizedRootGroup" } + expect(recreated&.explicit_folders).to eq(["Generated"]) + end + + context "when duplicates are allowed" do + before { Kintsugi::Settings.allow_duplicates = true } + + after { Kintsugi::Settings.allow_duplicates = false } + + it "adds a synchronized root group that already exists" do + existing_group = add_synchronized_root_group(base_project, "SyncedSources") + base_project.targets[0].file_system_synchronized_groups << existing_group + + theirs_project = create_copy_of_project(base_project, "theirs") + theirs_group = add_synchronized_root_group(theirs_project, "SyncedSources") + theirs_project.targets[0].file_system_synchronized_groups << theirs_group + + changes_to_apply = get_diff(theirs_project, base_project) + other_project = create_copy_of_project(base_project, "other") + described_class.apply_change_to_project(other_project, changes_to_apply, theirs_project) + + expect(other_project.targets[0].file_system_synchronized_groups.count).to eq(2) + end + + it "still does not duplicate an exception on an existing folder" do + group = add_synchronized_root_group(base_project, "Models") + base_project.targets[0].file_system_synchronized_groups << group + common_base = create_copy_of_project(base_project, "base") + + klass = Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet + theirs_project = create_copy_of_project(base_project, "theirs") + synchronized_group = theirs_project.objects.find do |object| + object.isa == "PBXFileSystemSynchronizedRootGroup" + end + exception = theirs_project.new(klass) + exception.target = theirs_project.targets[0] + exception.membership_exceptions = ["Excluded.swift"] + synchronized_group.exceptions << exception + + changes = get_diff(theirs_project, common_base) + described_class.apply_change_to_project(base_project, changes, theirs_project) + + # The two-pass traversal visits the same source exception twice; that artifact must be + # suppressed even when duplicates are allowed (an identical exception set is meaningless). + expect(base_project.objects.count { |object| object.is_a?(klass) }).to eq(1) + end + + it "still does not duplicate a deferred build-phase exception on an existing folder" do + group = add_synchronized_root_group(base_project, "Models") + base_project.targets[0].file_system_synchronized_groups << group + common_base = create_copy_of_project(base_project, "base") + + klass = Xcodeproj::Project::PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet + theirs_project = create_copy_of_project(base_project, "theirs") + synchronized_group = theirs_project.objects.find do |object| + object.isa == "PBXFileSystemSynchronizedRootGroup" + end + exception = theirs_project.new(klass) + exception.build_phase = theirs_project.targets[0].source_build_phase + exception.membership_exceptions = ["Excluded.swift"] + synchronized_group.exceptions << exception + + changes = get_diff(theirs_project, common_base) + described_class.apply_change_to_project(base_project, changes, theirs_project) + + expect(base_project.objects.count { |object| object.is_a?(klass) }).to eq(1) + end + end + end + def create_copy_of_project(project, new_project_prefix) copied_project_path = make_temp_directory(new_project_prefix, ".xcodeproj") project.save(copied_project_path) diff --git a/spec/kintsugi_integration_spec.rb b/spec/kintsugi_integration_spec.rb index 575d52b..a769df3 100644 --- a/spec/kintsugi_integration_spec.rb +++ b/spec/kintsugi_integration_spec.rb @@ -125,6 +125,51 @@ expect(`git -C #{git_directory_path} diff --name-only --diff-filter=U`.chomp) .to eq("#{project_name}/project.pbxproj") end + + it "resolves conflicts when adding a file system synchronized root group" do + File.write(File.join(git_directory_path, ".gitattributes"), "*.pbxproj merge=Unset") + + project = create_new_project_at_path(File.join(git_directory_path, project_name)) + + git.add(File.join(git_directory_path, ".gitattributes")) + git.add(project.path) + git.commit("Initial project") + + # A buildable folder, as created by Xcode 16: a `PBXFileSystemSynchronizedRootGroup` that + # lives in the main group and is referenced by the target. Before this feature, merging any + # conflict on such a project failed with "Trying to add unsupported component type + # PBXFileSystemSynchronizedRootGroup". + target = project.new_target("com.apple.product-type.library.static", "foo", :ios) + group = project.new(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup) + group.source_tree = "" + group.path = "SyncedSources" + project.main_group.children << group + target.file_system_synchronized_groups << group + project.save + + git.add(all: true) + git.commit("Add target foo with a buildable folder") + first_commit_hash = git.revparse("HEAD") + + git.checkout("HEAD^") + project = Xcodeproj::Project.open(project.path) + project.new_target("com.apple.product-type.library.static", "bar", :ios) + project.save + git.add(all: true) + git.commit("Add target bar") + + `git -C #{git_directory_path} #{git_command} #{first_commit_hash} &> /dev/null` + Kintsugi.run([File.join(project.path, "project.pbxproj")]) + + project = Xcodeproj::Project.open(project.path) + synchronized_groups = project.objects.select do |object| + object.isa == "PBXFileSystemSynchronizedRootGroup" + end + expect(project.targets.map(&:display_name)).to contain_exactly("foo", "bar") + expect(synchronized_groups.count).to eq(1) + expect(project.targets.find { |native_target| native_target.display_name == "foo" } + .file_system_synchronized_groups.first).to equal(synchronized_groups.first) + end end def make_temp_directory