From 1a672d01097b2fe0c258df4c41a4e7c34d470a49 Mon Sep 17 00:00:00 2001 From: Ryan Zulkoski Date: Thu, 10 Sep 2026 14:02:40 -0500 Subject: [PATCH] Support converting groups to and from Xcode 16 buildable folders Xcode 16 can convert a plain group (PBXGroup) into a buildable folder (PBXFileSystemSynchronizedRootGroup) or back. The two are unrelated classes, so the change appears as an in-place `isa` change on a node that keeps its name and location, which the resolver could not apply (it raised "Unsupported removed change type for PBXGroup"). Handle both directions: folder->group before files are added (so restored children land in the new group), group->folder after the group's explicit children are removed (so those removals can still navigate the group). Tear the old node down through a build-file-cleaning removal so none are left dangling, and make any group/file addition whose path is at or under a buildable folder a no-op, since a folder includes its descendants implicitly. When both sides convert the same node with diverging attributes -- which a conversion diff cannot merge automatically -- surface a conflict for manual resolution rather than dropping the change. Co-Authored-By: Claude Opus 4.8 --- lib/kintsugi/apply_change_to_project.rb | 177 +++++++- lib/kintsugi/xcodeproj_extensions.rb | 11 +- spec/kintsugi_apply_change_to_project_spec.rb | 385 ++++++++++++++++++ 3 files changed, 568 insertions(+), 5 deletions(-) diff --git a/lib/kintsugi/apply_change_to_project.rb b/lib/kintsugi/apply_change_to_project.rb index 0e925b2..c7cca7c 100644 --- a/lib/kintsugi/apply_change_to_project.rb +++ b/lib/kintsugi/apply_change_to_project.rb @@ -83,6 +83,7 @@ def apply_change_to_project(project, change, change_source_project) def apply_main_group_change(project, main_group_change) additions, removals, diffs = classify_group_and_file_changes(main_group_change, "") + apply_isa_conversions(project, diffs) apply_group_additions(project, additions) apply_file_changes(project, additions, removals) apply_group_and_file_diffs(project, diffs) @@ -121,6 +122,11 @@ def apply_group_additions(project, additions, force_create_containing_group: fal additions.each do |change, path| next unless GROUP_PIPELINE_ISAS.include?(change["isa"]) + # If the destination path is at or under a buildable folder in this project (e.g. this side + # converted the group while the other side added a subgroup or nested file under it), the + # folder includes its descendants implicitly -- there is no explicit child object to add. + next if path_within_synchronized_root_group?(project, path) + group_type = Module.const_get("Xcodeproj::Project::#{change["isa"]}") containing_group = project.group_or_file_at_path(path) @@ -167,16 +173,29 @@ def file_reference_key(change) .to_multi_h removal_keys_to_references = file_removals.to_multi_h.map do |change, paths| references = paths.map do |containing_path| - project[join_path(containing_path, change["displayName"])] + # Buildable-folder-safe lookup: a removed file whose containing path is now inside a + # folder converted from a group in the same change resolves to nil rather than raising. + project.group_or_file_at_path(join_path(containing_path, change["displayName"])) end [file_reference_key(change), references] end.to_h file_additions.each do |change, path| - containing_group = project.group_or_file_at_path(path) change_key = file_reference_key(change) + # If the destination path is at or under a buildable folder in this project (e.g. this side + # converted the group while the other side added a file into it), the folder includes the + # file implicitly -- there is no explicit reference to add. Drop any moved source reference + # so it is not left behind, then skip; otherwise `apply_file_addition` would call `children` + # on the folder and raise. + if path_within_synchronized_root_group?(project, path) + (removal_keys_to_references[change_key] || []).compact.each(&:remove_from_project) + next + end + + containing_group = project.group_or_file_at_path(path) + if containing_group.nil? if !force_create_containing_group && !ConflictResolver.create_nonexistent_group_when_adding_file?(path, @@ -205,7 +224,7 @@ def file_reference_key(change) file_removals.each do |change, path| next unless addition_keys_to_paths[file_reference_key(change)].nil? - file_reference = project[join_path(path, change["displayName"])] + file_reference = project.group_or_file_at_path(join_path(path, change["displayName"])) remove_component(file_reference, change) end end @@ -227,6 +246,10 @@ def apply_file_addition(containing_group, change, path) def apply_group_and_file_diffs(project, diffs) diffs.each do |change, path| + # A folder->group conversion is handled up front by `apply_isa_conversions` (before files + # are added), so the recreated group already exists here with nothing left to apply for it. + next if converts_from_synchronized_root_group?(change) + component = project.group_or_file_at_path(path) if component.nil? && change&.keys != ["children"] @@ -239,6 +262,22 @@ def apply_group_and_file_diffs(project, diffs) component = create_nonexistent_groupable_component(project, path) end + # A group->buildable-folder conversion replaces the node's type in place. It is handled + # here, after `apply_file_changes` has removed the group's explicit children, so that those + # file removals could still navigate the group. The old group is removed and a folder + # recreated at the same path. If this project already holds the folder (both sides converted + # the same group), there is nothing to tear down; the conflict check surfaces any diverging + # folder attributes rather than silently dropping them. + if converts_to_synchronized_root_group?(change) + if component.is_a?(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup) + raise_on_diverging_both_sides_conversion(component, path) + elsif !component.nil? + remove_groupable_component_for_conversion(component) + create_nonexistent_groupable_component(project, path) + end + next + end + change.each do |subchange_name, subchange| next if subchange_name == "children" @@ -247,6 +286,133 @@ def apply_group_and_file_diffs(project, diffs) end end + # Xcode 16 can convert a plain group (`PBXGroup`) into a buildable folder + # (`PBXFileSystemSynchronizedRootGroup`) or back. The two are unrelated classes, so the change + # is expressed as an in-place `isa` change on a node that keeps its name and location, which + # can't be applied as an ordinary attribute; the old node is removed and a new one of the + # target type is created at the same path. + # + # Only the folder->group direction is handled here, before files are added or removed, so that + # the conversion's restored child files land in the newly created group -- a buildable folder + # has no navigable `children`, so adding them to the not-yet-converted node would raise. The + # group->folder direction is handled later, in `apply_group_and_file_diffs`, because the group + # must stay navigable until `apply_file_changes` has removed its explicit children. + def apply_isa_conversions(project, diffs) + diffs.each do |change, path| + next unless converts_from_synchronized_root_group?(change) + + component = project.group_or_file_at_path(path) + + if component.is_a?(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup) + # This project still holds the buildable folder: convert it to the plain group. The normal + # additions/file-changes path then merges the other side's restored children in. + remove_groupable_component_for_conversion(component) + create_nonexistent_groupable_component(project, path) + elsif !component.nil? + # This project already holds a plain group here -- both sides ran the same folder->group + # conversion. There is nothing to tear down (ours-only children still merge via the normal + # path), but surface any diverging attributes rather than silently dropping them. + raise_on_diverging_both_sides_conversion(component, path) + end + end + end + + # When both sides of a merge independently convert the same node to the same type (group or + # buildable folder), `change` for that node conflates the structural attributes both sides + # applied identically with any attribute the other side additionally changed -- so the latter + # can't be cleanly merged onto the node this project already holds. Rather than silently drop + # such a change, compare the two converted nodes' own attributes (children merge separately, so + # they are ignored) and raise a conflict when they diverge, leaving it for the user to resolve. + def raise_on_diverging_both_sides_conversion(component, path) + source_component = @change_source_project.group_or_file_at_path(path) + return if source_component.nil? + return if own_conversion_attributes(component) == own_conversion_attributes(source_component) + + raise MergeError, "Both sides converted the node at '#{path}' to the same type but with " \ + "different attributes, which cannot be merged automatically. Resolve the " \ + "conflict for this file manually." + end + + def own_conversion_attributes(node) + canonicalize(node.to_tree_hash.reject { |key, _| %w[children displayName].include?(key) }) + end + + # Canonicalizes a tree-hash value so semantically-equal attributes compare equal regardless of + # hash key order or the order of set-like arrays. A buildable folder's `exceptions` and + # `explicitFolders` are order-independent sets, so a pure reordering must not read as a + # divergence: hashes become key-sorted pair lists and arrays are sorted by their canonical form. + def canonicalize(value) + case value + when Hash + value.sort_by { |key, _| key.to_s }.map { |key, subvalue| [key, canonicalize(subvalue)] } + when Array + value.map { |element| canonicalize(element) }.sort_by(&:inspect) + else + value + end + end + + # Whether `change` is an in-place `isa` change whose new type is a buildable folder, i.e. a + # group->folder conversion. Restricting to this exact case (rather than any `isa` change) keeps + # the remove-and-recreate path away from unrelated in-place `isa` changes (e.g. a + # `PBXVariantGroup` to `PBXGroup`), whose unchanged children must be preserved rather than + # dropped. + def converts_to_synchronized_root_group?(change) + isa_conversion_added_type(change) == "PBXFileSystemSynchronizedRootGroup" + end + + # Whether `change` is an in-place `isa` change whose old type was a buildable folder, i.e. a + # folder->group conversion. + def converts_from_synchronized_root_group?(change) + isa_conversion_removed_type(change) == "PBXFileSystemSynchronizedRootGroup" + end + + def isa_conversion_added_type(change) + isa_change = change.is_a?(Hash) ? change["isa"] : nil + isa_change.is_a?(Hash) ? isa_change[:added] || isa_change["added"] : nil + end + + def isa_conversion_removed_type(change) + isa_change = change.is_a?(Hash) ? change["isa"] : nil + isa_change.is_a?(Hash) ? isa_change[:removed] || isa_change["removed"] : nil + end + + # Whether `path` (a "/"-separated path from the main group) lies at or under a buildable folder + # (`PBXFileSystemSynchronizedRootGroup`) in `project`. A buildable folder includes its + # descendants implicitly, so an explicit group/file addition at such a path must be skipped + # rather than navigated into (a folder has no `children`). Walks the path shallowest-first, + # returning at the first folder (match) or the first segment that does not resolve (no folder + # sits above the change -- the path is genuinely absent and handled normally). + def path_within_synchronized_root_group?(project, path) + return false if path.nil? || path.empty? + + segments = path.split("/") + (1..segments.length).each do |depth| + node = project.group_or_file_at_path(segments.first(depth).join("/")) + return true if node.is_a?(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup) + return false if node.nil? + end + false + end + + # Removes a node that is being converted to or from a buildable folder. The subtree is torn down + # bottom-up: each file reference's build files are removed first (so none are left dangling once + # the file references go away), then every descendant object is detached (so none are left + # orphaned once the node is replaced), then the node itself. A buildable folder includes its + # files implicitly, so their explicit references and build-phase membership are no longer + # represented. + def remove_groupable_component_for_conversion(component) + if component.respond_to?(:recursive_children) + component.recursive_children.reverse_each do |child| + if child.is_a?(Xcodeproj::Project::PBXFileReference) + remove_build_files_of_file_reference(child) + end + child.remove_from_project + end + end + component.remove_from_project + end + def create_nonexistent_groupable_component(project, path) source_project_component = @change_source_project.group_or_file_at_path(path) component_change = source_project_component.to_tree_hash @@ -280,7 +446,10 @@ def apply_group_removals(project, removals) change_without_children = change.dup change_without_children["children"] = [] if change.key?("children") - remove_component(project[group_path], change_without_children) + # Use the buildable-folder-safe lookup: a group nested under a folder that was converted to + # a buildable folder in the same change no longer exists as a navigable object (it was + # removed with its parent), so this resolves to nil and the removal becomes a no-op. + remove_component(project.group_or_file_at_path(group_path), change_without_children) end end diff --git a/lib/kintsugi/xcodeproj_extensions.rb b/lib/kintsugi/xcodeproj_extensions.rb index bdc1ee3..86a2519 100644 --- a/lib/kintsugi/xcodeproj_extensions.rb +++ b/lib/kintsugi/xcodeproj_extensions.rb @@ -13,7 +13,16 @@ class Project # # @return [PBXGroup/PBXVariantGroup/PBXFileReference] def group_or_file_at_path(path) - path.empty? ? self.main_group : self[path] + return self.main_group if path.empty? + + # A path segment may traverse a `PBXFileSystemSynchronizedRootGroup` (Xcode 16 buildable + # folder), whose contents are implicit and not navigable objects. `find_subpath` raises a + # `NoMethodError` in that case; there is no explicit object at such a path, so return `nil`. + begin + self[path] + rescue NoMethodError + nil + end end # Extends `ObjectDictionary` to act like an `Object` if `self` repreresents a project reference. diff --git a/spec/kintsugi_apply_change_to_project_spec.rb b/spec/kintsugi_apply_change_to_project_spec.rb index a387acf..0872036 100644 --- a/spec/kintsugi_apply_change_to_project_spec.rb +++ b/spec/kintsugi_apply_change_to_project_spec.rb @@ -2258,6 +2258,391 @@ def add_synchronized_root_group(project, path, source_tree: "") .to eq(0) end + it "converts an existing group into a file system synchronized root group" do + routines = base_project.main_group.new_group("Routines") + routines.new_file("Routines/Routine.swift") + + theirs_project = create_copy_of_project(base_project, "theirs") + theirs_project.main_group.children + .find { |child| child.display_name == "Routines" } + .remove_from_project + folder = add_synchronized_root_group(theirs_project, "Routines") + theirs_project.targets[0].file_system_synchronized_groups << folder + + 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) + + # The group is replaced in place: no `PBXGroup` named "Routines" remains, and the single + # buildable folder is the exact object linked to the target (not a duplicate with the same + # attributes). + expect(base_project.objects.any? { |o| o.isa == "PBXGroup" && o.display_name == "Routines" }) + .to be false + folders = base_project.objects.select { |o| o.isa == "PBXFileSystemSynchronizedRootGroup" } + expect(folders.count).to eq(1) + expect(base_project.targets[0].file_system_synchronized_groups.first).to equal(folders.first) + end + + it "converts a group with nested children into a file system synchronized root group" do + routines = base_project.main_group.new_group("Routines") + routines.new_file("Routines/Routine.swift") + detail = routines.new_group("Detail") + detail.new_file("Routines/Detail/Detail.swift") + + theirs_project = create_copy_of_project(base_project, "theirs") + theirs_project.main_group.children + .find { |child| child.display_name == "Routines" } + .remove_from_project + folder = add_synchronized_root_group(theirs_project, "Routines") + theirs_project.targets[0].file_system_synchronized_groups << folder + + 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) + + # The whole subtree (the group and its nested "Detail" subgroup) is gone, replaced by one + # buildable folder. Removing the nested subgroup exercises the buildable-folder-safe path + # lookup, which resolves the vanished nested path to a no-op instead of raising. + expect(base_project.objects.any? { |o| o.isa == "PBXGroup" && o.display_name == "Routines" }) + .to be false + expect(base_project.objects.any? { |o| o.isa == "PBXGroup" && o.display_name == "Detail" }) + .to be false + expect(base_project.objects.count { |o| o.isa == "PBXFileSystemSynchronizedRootGroup" }) + .to eq(1) + end + + it "converts a group nested under another group into a synchronized root group" do + scenes = base_project.main_group.new_group("Scenes") + routines = scenes.new_group("Routines") + routines.new_file("Scenes/Routines/Routine.swift") + + theirs_project = create_copy_of_project(base_project, "theirs") + theirs_scenes = theirs_project.main_group.children.find { |c| c.display_name == "Scenes" } + theirs_scenes.children.find { |c| c.display_name == "Routines" }.remove_from_project + folder = theirs_project.new(Xcodeproj::Project::PBXFileSystemSynchronizedRootGroup) + folder.source_tree = "" + folder.path = "Routines" + theirs_scenes.children << folder + theirs_project.targets[0].file_system_synchronized_groups << folder + + 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) + + # The folder lands at the same location in the tree, nested under the "Scenes" group. + scenes_group = base_project.main_group.children.find { |c| c.display_name == "Scenes" } + folder_in_base = + base_project.objects.find { |o| o.isa == "PBXFileSystemSynchronizedRootGroup" } + expect(scenes_group.children).to include(folder_in_base) + end + + it "converts a synchronized root group back into a plain group" do + folder = add_synchronized_root_group(base_project, "Routines") + base_project.targets[0].file_system_synchronized_groups << folder + + theirs_project = create_copy_of_project(base_project, "theirs") + theirs_folder = theirs_project.main_group.children + .find { |child| child.display_name == "Routines" } + theirs_project.targets[0].file_system_synchronized_groups.delete(theirs_folder) + theirs_folder.remove_from_project + restored_group = theirs_project.main_group.new_group("Routines") + restored_group.new_file("Routines/Routine.swift") + + 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) + + # The buildable folder is gone, replaced by a plain group whose restored child file was added + # into the new group rather than into the vanished folder (the reverse-direction crash). + expect(base_project.objects.count { |o| o.isa == "PBXFileSystemSynchronizedRootGroup" }) + .to eq(0) + group = base_project.main_group.children.find { |child| child.display_name == "Routines" } + expect(group.isa).to eq("PBXGroup") + expect(group.children.map(&:display_name)).to include("Routine.swift") + end + + it "does not leave a dangling build file when a local edit diverges during conversion" do + routines = base_project.main_group.new_group("Routines") + routines.new_file("Routines/Routine.swift") + + # ours independently adds a file to the same group and compiles it into the target. + ours_project = create_copy_of_project(base_project, "ours") + ours_routines = ours_project.main_group.children + .find { |child| child.display_name == "Routines" } + local_file = ours_routines.new_file("Routines/LocalOnly.swift") + ours_project.targets[0].source_build_phase.add_file_reference(local_file) + + # theirs converts that same group into a buildable folder. + theirs_project = create_copy_of_project(base_project, "theirs") + theirs_project.main_group.children + .find { |child| child.display_name == "Routines" } + .remove_from_project + folder = add_synchronized_root_group(theirs_project, "Routines") + theirs_project.targets[0].file_system_synchronized_groups << folder + + changes_to_apply = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(ours_project, changes_to_apply, theirs_project) + + # The group became a buildable folder (which includes its files implicitly). No build file is + # left referencing a file reference that is no longer reachable in the project tree. + expect(ours_project.objects.count { |o| o.isa == "PBXFileSystemSynchronizedRootGroup" }) + .to eq(1) + reachable_file_references = + ours_project.main_group.recursive_children.grep(Xcodeproj::Project::PBXFileReference) + dangling_build_files = + ours_project.objects.select { |o| o.isa == "PBXBuildFile" }.reject do |build_file| + build_file.file_ref.nil? || reachable_file_references.include?(build_file.file_ref) + end + expect(dangling_build_files).to be_empty + end + + it "does not treat an unrelated in-place isa change as a buildable-folder conversion" do + variant_group = base_project.new(Xcodeproj::Project::PBXVariantGroup) + variant_group.name = "Strings" + base_project.main_group.children << variant_group + + theirs_project = create_copy_of_project(base_project, "theirs") + theirs_project.main_group.children + .find { |child| child.display_name == "Strings" } + .remove_from_project + theirs_project.main_group.new_group("Strings") + + changes_to_apply = get_diff(theirs_project, base_project) + + # The narrowed conversion guard leaves this to the normal path, which surfaces the unsupported + # in-place isa change rather than silently discarding the node through the remove-and-recreate + # path that is reserved for buildable-folder conversions. + expect { + described_class.apply_change_to_project(base_project, changes_to_apply, theirs_project) + }.to raise_error(Kintsugi::MergeError) + end + + it "preserves a local file when both sides convert the same folder back into a group" do + folder = add_synchronized_root_group(base_project, "Routines") + base_project.targets[0].file_system_synchronized_groups << folder + + # ours reverts the folder to a plain group and adds and compiles a local file. + ours_project = create_copy_of_project(base_project, "ours") + ours_folder = ours_project.main_group.children.find { |c| c.display_name == "Routines" } + ours_project.targets[0].file_system_synchronized_groups.delete(ours_folder) + ours_folder.remove_from_project + ours_group = ours_project.main_group.new_group("Routines") + local_file = ours_group.new_file("Routines/LocalOnly.swift") + ours_project.targets[0].source_build_phase.add_file_reference(local_file) + + # theirs reverts the same folder to a plain group and adds a different file. + theirs_project = create_copy_of_project(base_project, "theirs") + theirs_folder = theirs_project.main_group.children.find { |c| c.display_name == "Routines" } + theirs_project.targets[0].file_system_synchronized_groups.delete(theirs_folder) + theirs_folder.remove_from_project + theirs_group = theirs_project.main_group.new_group("Routines") + theirs_group.new_file("Routines/Routine.swift") + + changes_to_apply = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(ours_project, changes_to_apply, theirs_project) + + # Both sides' files survive: the local file is merged into the group rather than discarded by + # the conversion pre-pass, and its compile membership is preserved. + group = ours_project.main_group.children.find { |c| c.display_name == "Routines" } + expect(group.isa).to eq("PBXGroup") + expect(group.children.map(&:display_name)) + .to contain_exactly("LocalOnly.swift", "Routine.swift") + compiled = ours_project.targets[0].source_build_phase.files.map { |bf| bf.file_ref&.display_name } + expect(compiled).to include("LocalOnly.swift") + expect(ours_project.objects.count { |o| o.isa == "PBXFileSystemSynchronizedRootGroup" }) + .to eq(0) + end + + it "does not fail when both sides convert the same group into a buildable folder" do + routines = base_project.main_group.new_group("Routines") + routines.new_file("Routines/Routine.swift") + + ours_project = create_copy_of_project(base_project, "ours") + ours_project.main_group.children + .find { |c| c.display_name == "Routines" } + .remove_from_project + ours_folder = add_synchronized_root_group(ours_project, "Routines") + ours_project.targets[0].file_system_synchronized_groups << ours_folder + + theirs_project = create_copy_of_project(base_project, "theirs") + theirs_project.main_group.children + .find { |c| c.display_name == "Routines" } + .remove_from_project + theirs_folder = add_synchronized_root_group(theirs_project, "Routines") + theirs_project.targets[0].file_system_synchronized_groups << theirs_folder + + changes_to_apply = get_diff(theirs_project, base_project) + described_class.apply_change_to_project(ours_project, changes_to_apply, theirs_project) + + expect(ours_project).to be_equivalent_to_project(theirs_project) + expect(ours_project.objects.count { |o| o.isa == "PBXFileSystemSynchronizedRootGroup" }) + .to eq(1) + end + + it "surfaces a conflict when both sides convert the same node but with diverging attributes" do + routines = base_project.main_group.new_group("Routines") + routines.new_file("Routines/Routine.swift") + + # ours converts the group into a buildable folder. + ours_project = create_copy_of_project(base_project, "ours") + ours_project.main_group.children + .find { |c| c.display_name == "Routines" } + .remove_from_project + ours_folder = add_synchronized_root_group(ours_project, "Routines") + ours_project.targets[0].file_system_synchronized_groups << ours_folder + + # theirs converts it into a buildable folder too, but sets a diverging folder attribute. + theirs_project = create_copy_of_project(base_project, "theirs") + theirs_project.main_group.children + .find { |c| c.display_name == "Routines" } + .remove_from_project + theirs_folder = add_synchronized_root_group(theirs_project, "Routines") + theirs_folder.explicit_file_types = {"*.md" => "text"} + theirs_project.targets[0].file_system_synchronized_groups << theirs_folder + + changes_to_apply = get_diff(theirs_project, base_project) + + # The diverging attribute can't be merged onto the folder this side already holds, so rather + # than dropping it silently the merge is surfaced as a conflict for the user to resolve. + expect { + described_class.apply_change_to_project(ours_project, changes_to_apply, theirs_project) + }.to raise_error(Kintsugi::MergeError) + end + + it "merges when both sides convert the same node with set attributes in a different order" do + routines = base_project.main_group.new_group("Routines") + routines.new_file("Routines/Routine.swift") + + ours_project = create_copy_of_project(base_project, "ours") + ours_project.main_group.children + .find { |c| c.display_name == "Routines" } + .remove_from_project + ours_folder = add_synchronized_root_group(ours_project, "Routines") + ours_folder.explicit_folders = %w[A B] + ours_project.targets[0].file_system_synchronized_groups << ours_folder + + theirs_project = create_copy_of_project(base_project, "theirs") + theirs_project.main_group.children + .find { |c| c.display_name == "Routines" } + .remove_from_project + theirs_folder = add_synchronized_root_group(theirs_project, "Routines") + theirs_folder.explicit_folders = %w[B A] + theirs_project.targets[0].file_system_synchronized_groups << theirs_folder + + changes_to_apply = get_diff(theirs_project, base_project) + + # The two folders hold the same set of explicit folders, only in a different order, which is + # not a real divergence -- the merge must not raise a spurious conflict. + expect { + described_class.apply_change_to_project(ours_project, changes_to_apply, theirs_project) + }.not_to raise_error + end + + it "adds a file under a path this side converted to a buildable folder without failing" do + routines = base_project.main_group.new_group("Routines") + routines.new_file("Routines/Routine.swift") + + # ours converts the group into a buildable folder. + ours_project = create_copy_of_project(base_project, "ours") + ours_project.main_group.children + .find { |c| c.display_name == "Routines" } + .remove_from_project + folder = add_synchronized_root_group(ours_project, "Routines") + ours_project.targets[0].file_system_synchronized_groups << folder + + # theirs keeps the plain group and adds a new file into it (an ordinary, non-conversion edit). + theirs_project = create_copy_of_project(base_project, "theirs") + theirs_project.main_group.children + .find { |c| c.display_name == "Routines" } + .new_file("Routines/NewFile.swift") + + changes_to_apply = get_diff(theirs_project, base_project) + + # The added file's destination is now a buildable folder, which includes files implicitly, so + # the addition must be a no-op rather than raising when it reaches the folder. + expect { + described_class.apply_change_to_project(ours_project, changes_to_apply, theirs_project) + }.not_to raise_error + + expect(ours_project.objects.count { |o| o.isa == "PBXFileSystemSynchronizedRootGroup" }) + .to eq(1) + expect(ours_project.objects.any? { |o| o.isa == "PBXGroup" && o.display_name == "Routines" }) + .to be false + expect(ours_project.objects.any? do |o| + o.isa == "PBXFileReference" && o.display_name == "NewFile.swift" + end).to be false + end + + it "adds a subgroup and nested file under a converted buildable folder without failing" do + routines = base_project.main_group.new_group("Routines") + routines.new_file("Routines/Routine.swift") + + # ours converts the group into a buildable folder. + ours_project = create_copy_of_project(base_project, "ours") + ours_project.main_group.children + .find { |c| c.display_name == "Routines" } + .remove_from_project + folder = add_synchronized_root_group(ours_project, "Routines") + ours_project.targets[0].file_system_synchronized_groups << folder + + # theirs keeps the plain group and adds a nested subgroup containing a file. + theirs_project = create_copy_of_project(base_project, "theirs") + theirs_sub = theirs_project.main_group.children + .find { |c| c.display_name == "Routines" } + .new_group("Sub") + theirs_sub.new_file("Routines/Sub/Deep.swift") + + changes_to_apply = get_diff(theirs_project, base_project) + + # The subgroup and the file nested under it both land at a path that is now a buildable folder, + # which includes its descendants implicitly, so both additions must be no-ops rather than + # dereferencing `children` on the folder. + expect { + described_class.apply_change_to_project(ours_project, changes_to_apply, theirs_project) + }.not_to raise_error + + expect(ours_project.objects.count { |o| o.isa == "PBXFileSystemSynchronizedRootGroup" }) + .to eq(1) + expect(ours_project.objects.any? { |o| o.isa == "PBXGroup" && o.display_name == "Sub" }) + .to be false + expect(ours_project.objects.any? do |o| + o.isa == "PBXFileReference" && o.display_name == "Deep.swift" + end).to be false + end + + it "converts a folder that carried build file exceptions back into a plain group" do + folder = add_synchronized_root_group(base_project, "Routines") + base_project.targets[0].file_system_synchronized_groups << folder + exception_set = + base_project.new(Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet) + exception_set.target = base_project.targets[0] + exception_set.membership_exceptions = ["Excluded.swift"] + folder.exceptions << exception_set + + theirs_project = create_copy_of_project(base_project, "theirs") + theirs_folder = theirs_project.main_group.children.find { |c| c.display_name == "Routines" } + theirs_project.targets[0].file_system_synchronized_groups.delete(theirs_folder) + theirs_folder.remove_from_project + theirs_group = theirs_project.main_group.new_group("Routines") + theirs_group.new_file("Routines/Routine.swift") + + 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) + + # The folder is gone, and its exception set is torn down with it -- no orphan remains. + expect(base_project.objects.count { |o| o.isa == "PBXFileSystemSynchronizedRootGroup" }) + .to eq(0) + expect(base_project.objects.any? do |o| + o.isa == "PBXFileSystemSynchronizedBuildFileExceptionSet" + end).to be false + 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")