Support PBXFileSystemSynchronizedRootGroup (Xcode 16 buildable folders) - #141
Conversation
Noam Freeman (noamfreeman)
left a comment
There was a problem hiding this comment.
Hi Ryan Zulkoski (@rzulkoski) ! thanks for the implementation.
i found 2 things worth fixing before merging(and some other minor issues - in the comments):
- When a target and its synchronized-folder exception are added together, the group is processed before the target exists, so the target or build-phase reference can remain unset. Deferring reference resolution until targets are created—or doing a second pass—may help.
- Groups and build phases are resolved globally by display name. Common names such as
Sourcescan silently select an object belonging to another target or parent group. Target/hierarchy information or source-object identity may provide a safer match.
tests for reproduction
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)
endthe inline comments are of lower severety. all the issues are in the new feature, not regressions, so this current implementation is also a big improvement.
| case source_project_component | ||
| when Xcodeproj::Project::PBXFileReference | ||
| apply_file_changes(project, [[component_change, containing_group_path]], [], | ||
| force_create_containing_group: true) | ||
| when Xcodeproj::Project::PBXGroup | ||
| apply_group_additions(project, [[component_change, containing_group_path]], | ||
| force_create_containing_group: true) | ||
| else | ||
| raise MergeError, "Component should either be a group or a file reference. " \ | ||
| "Instead got: #{source_project_component}" | ||
| end |
There was a problem hiding this comment.
a delete vs. modify conflict may reach the else error case,
can we include PBXFileSystemSynchronizedRootGroup in this recreation path? maybe pass it to apply_group_additions?
There was a problem hiding this comment.
Done — create_nonexistent_groupable_component now routes PBXFileSystemSynchronizedRootGroup to apply_group_additions (alongside PBXGroup), so a delete-vs-modify conflict recreates the folder instead of hitting the else. Covered by the "recreates a synchronized root group modified on theirs and deleted on ours" spec.
| 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 | ||
|
|
||
| project = containing_component.project | ||
| exception_set = | ||
| project.new(Xcodeproj::Project::PBXFileSystemSynchronizedBuildFileExceptionSet) |
There was a problem hiding this comment.
small edge case: if base doesn't have an exception set, but the 2 branches do, we should reuse/merge the existing one.
same for the build-phase exception below.
There was a problem hiding this comment.
Done for both variants — exception sets are now deduped before being added, so an equivalent set already on the group is a no-op. Because a folder is referenced from both the main group and its target(s), the same addition is visited through several graph paths; the dedup handles that too (and stays correct while the reference is still deferred/unresolved). Specs: "does not duplicate an exception set added on both sides", "...deferred exception added to an existing buildable folder", plus the allow_duplicates variants.
| # `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 |
There was a problem hiding this comment.
Can we set a display_name in the same manner as PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet?
if non is supplied, the inherited target.name may be dereferenced on a nil target.
There was a problem hiding this comment.
Done — PBXFileSystemSynchronizedBuildFileExceptionSet now has a nil-safe display_name override mirroring the membership variant (using target&.name), so an unset target no longer dereferences nil. Spec: "serializes a build file exception set with an unset target without crashing".
Xcode 16 "buildable folders" are stored as PBXFileSystemSynchronizedRootGroup objects, referenced both from the main group tree and from a target's fileSystemSynchronizedGroups. Kintsugi had no handling for this isa, so merging any conflict touching such a folder failed with "Trying to add unsupported component type PBXFileSystemSynchronizedRootGroup". This implements complete support, addressing the review feedback on Lightricks#139: - Handle the folder in the main group addition/removal pipeline instead of dropping it (also fixing a phantom empty-children key that blocked removals). - Attach it to a target by resolving and reusing the object already created in the group tree, identifying it by hierarchy path so same-named folders in different parent groups (and folders whose path contains "/") resolve correctly; dedup by object identity so one target can link several distinct folders that share a name. - Defer exception target/build-phase resolution to a final pass, so references to targets created in the same change resolve; resolve a build phase via its owning target, avoiding same-named ("Sources") collisions across targets. - Dedup exception sets (which arrive via multiple graph paths), and detach a target's folder reference on unlink instead of deleting the shared object. - Support target-membership and build-phase-membership exceptions, with to_tree_hash monkeypatches that break the target/build_phase reference cycle (which otherwise infinitely recurses) and work around an xcodeproj 1.27.0 bug where the membership exception set's display_name raises. Adds extensive specs (both references, exceptions, removal/unlink, dedup, same-named and slashed-path folders, deferred resolution) plus an integration spec reproducing the original merge failure. Known limitation: relocating a folder between parent groups on one side can drop its target membership on merge, since folders have no stable identity in the diff; re-verify membership after such merges. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
03ed6d3 to
b234212
Compare
|
Thanks for the thorough review, Noam Freeman (@noamfreeman) — I've pushed an update addressing all of it. The two main issues:
The inline comments:
While in there I also ran an adversarial pass over the whole feature and fixed a few pre-existing edges it turned up — notably a data-loss case where removing a target's link to a shared folder deleted the folder object project-wide (it now detaches the reference and lets the object survive for other targets), plus the reference-cycle and One known limitation I left documented in the code: relocating a folder between parent groups on one side can drop its target membership on merge, because buildable folders have no stable identity in the diff (a move reads as remove-here + add-there). A proper fix would need to correlate removals with additions across the change, which felt out of scope here.
|
|
Sounds good, thanks! |
Summary
Adds complete support for merging projects that use Xcode 16 buildable folders, stored as
PBXFileSystemSynchronizedRootGroup. These are referenced both from the main group tree and from a target'sfileSystemSynchronizedGroups; Kintsugi had no handling for the isa, so any conflict touching such a folder failed withTrying to add unsupported component type PBXFileSystemSynchronizedRootGroup.Builds on #139 by ivoidcat and implements the "proper fix" described in Noam Freeman (@noamfreeman)'s review there (built with Claude Code's help — the commit is co-authored accordingly):
apply_group_additions/apply_group_removals) instead of dropping it — the missing-from-the-group-tree case from review. Also fixes a phantom empty-childrenkey that would otherwise block removals.add_variant_group→find_variant_group), so one folder never yields two root groups. Guarded bySettings.allow_duplicates.respond_to?fallback and the no-op branches, per review.exceptions(target- and build-phase-membership), removals, and attribute diffs.The
exceptionssupport needed ato_tree_hashmonkeypatch (in the existingxcodeproj_extensions.rb) to break a reference cycle — the stockto_tree_hashexpands the exception set'starget/build_phasereference, which recurses back through the owning group forever. It also works around an xcodeproj bug wherePBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet#display_namecallsbuild_phase.name, which the referenced build phases (e.g.PBXSourcesBuildPhase) don't implement — present through the latest release (1.28.1) andmaster.No dependency changes; validated against the pinned
xcodeprojrange (>= 1.26.0, <= 1.27.0).Closes #139 (supersedes it — happy to keep it open instead if you'd rather).
Test plan
exceptions, removal, and theallow_duplicatesguard — including an object-identity assertion, since the tree-hash matcher can't detect a duplicated object.rebase/cherry-pick/merge).bundle exec rake(RuboCop + full suite): 150 examples, 0 failures; 0 offenses.🤖 Generated with Claude Code