From a0afcefaede2e1a5ff0d29a7e4f7bd79569cc6f0 Mon Sep 17 00:00:00 2001 From: mirkokg Date: Tue, 3 Mar 2026 22:22:27 +0100 Subject: [PATCH] Handle membership exceptions for synchronized root groups (#1587) * Handle membership exceptions for synchronized root groups Adds logic to detect and register membership exceptions for PBXFileSystemSynchronizedRootGroup objects, specifically excluding Info.plist files from group membership when necessary. Also ensures resources build phase is added if synchronized root groups are present. * Refactor synced folder membership exceptions with glob support Extract configureMembershipExceptions into its own method, use Set for dedup, resolve excludes via glob expansion, and add a no-op test case. Incorporates glob support and tests from macguru@baf1108. * Update UUID * Comment out excludes in project.yml Comment out excludes for ExcludedFile.swift due to CI issue. * Clean up project.pbxproj by removing exception set Removed PBXFileSystemSynchronizedBuildFileExceptionSet section and its references. * Remove comment * Update SourceGeneratorTests.swift * Update project.pbxproj * Retrigger CI * Add info.plist exclusion --- Sources/XcodeGenKit/PBXProjGenerator.swift | 51 +++++++++- Sources/XcodeGenKit/SourceGenerator.swift | 5 + .../Project.xcodeproj/project.pbxproj | 14 +++ .../SyncedFolder/ExcludedFile.swift | 1 + .../TestProject/SyncedFolder/Info.plist | 5 + Tests/Fixtures/TestProject/project.yml | 3 + .../SourceGeneratorTests.swift | 93 +++++++++++++++++++ 7 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 Tests/Fixtures/TestProject/SyncedFolder/ExcludedFile.swift create mode 100644 Tests/Fixtures/TestProject/SyncedFolder/Info.plist diff --git a/Sources/XcodeGenKit/PBXProjGenerator.swift b/Sources/XcodeGenKit/PBXProjGenerator.swift index 7c7f64f5..b8ab9d37 100644 --- a/Sources/XcodeGenKit/PBXProjGenerator.swift +++ b/Sources/XcodeGenKit/PBXProjGenerator.swift @@ -1141,7 +1141,8 @@ public class PBXProjGenerator { func addResourcesBuildPhase() { let resourcesBuildPhaseFiles = getBuildFilesForPhase(.resources) + copyResourcesReferences - if !resourcesBuildPhaseFiles.isEmpty { + let hasSynchronizedRootGroups = sourceFiles.contains { $0.fileReference is PBXFileSystemSynchronizedRootGroup } + if !resourcesBuildPhaseFiles.isEmpty || hasSynchronizedRootGroups { let resourcesBuildPhase = addObject(PBXResourcesBuildPhase(files: resourcesBuildPhaseFiles)) buildPhases.append(resourcesBuildPhase) } @@ -1460,9 +1461,57 @@ public class PBXProjGenerator { // add fileSystemSynchronizedGroups let synchronizedRootGroups = sourceFiles.compactMap { $0.fileReference as? PBXFileSystemSynchronizedRootGroup } if !synchronizedRootGroups.isEmpty { + for syncedGroup in synchronizedRootGroups { + configureMembershipExceptions( + for: syncedGroup, + target: target, + targetObject: targetObject, + infoPlistFiles: infoPlistFiles + ) + } targetObject.fileSystemSynchronizedGroups = synchronizedRootGroups } } + + private func configureMembershipExceptions( + for syncedGroup: PBXFileSystemSynchronizedRootGroup, + target: Target, + targetObject: PBXTarget, + infoPlistFiles: [Config: String] + ) { + guard let syncedGroupPath = syncedGroup.path else { return } + let syncedPath = (project.basePath + Path(syncedGroupPath)).normalize() + + guard let targetSource = target.sources.first(where: { + (project.basePath + $0.path).normalize() == syncedPath + }) else { return } + + var exceptions: Set = Set( + sourceGenerator.expandedExcludes(for: targetSource) + .compactMap { try? $0.relativePath(from: syncedPath).string } + ) + + for infoPlistPath in Set(infoPlistFiles.values) { + let relative = try? (project.basePath + infoPlistPath).normalize() + .relativePath(from: syncedPath) + if let rel = relative?.string, !rel.hasPrefix("..") { + exceptions.insert(rel) + } + } + + guard !exceptions.isEmpty else { return } + + let exceptionSet = PBXFileSystemSynchronizedBuildFileExceptionSet( + target: targetObject, + membershipExceptions: exceptions.sorted(), + publicHeaders: nil, + privateHeaders: nil, + additionalCompilerFlagsByRelativePath: nil, + attributesByRelativePath: nil + ) + addObject(exceptionSet) + syncedGroup.exceptions = (syncedGroup.exceptions ?? []) + [exceptionSet] + } private func makePlatformFilter(for filter: Dependency.PlatformFilter) -> String? { switch filter { diff --git a/Sources/XcodeGenKit/SourceGenerator.swift b/Sources/XcodeGenKit/SourceGenerator.swift index 35c914fc..06486f11 100644 --- a/Sources/XcodeGenKit/SourceGenerator.swift +++ b/Sources/XcodeGenKit/SourceGenerator.swift @@ -372,6 +372,11 @@ class SourceGenerator { return variantGroup } + /// Returns the expanded set of excluded paths for a target source by resolving its exclude glob patterns. + func expandedExcludes(for targetSource: TargetSource) -> Set { + getSourceMatches(targetSource: targetSource, patterns: targetSource.excludes) + } + /// Collects all the excluded paths within the targetSource private func getSourceMatches(targetSource: TargetSource, patterns: [String]) -> Set { let rootSourcePath = project.basePath + targetSource.path diff --git a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj index 10ff1c3a..eabdb4b1 100644 --- a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj +++ b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj @@ -830,9 +830,23 @@ FED40A89162E446494DDE7C7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 9A259ACEBCE19CC5F22B6DD4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + ExcludedFile.swift, + Info.plist, + ); + target = 0867B0DACEF28C11442DE8F7 /* App_iOS */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ AE2AB2772F70DFFF402AA02B /* SyncedFolder */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 9A259ACEBCE19CC5F22B6DD4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, + ); explicitFileTypes = { }; explicitFolders = ( diff --git a/Tests/Fixtures/TestProject/SyncedFolder/ExcludedFile.swift b/Tests/Fixtures/TestProject/SyncedFolder/ExcludedFile.swift new file mode 100644 index 00000000..d1c13a1f --- /dev/null +++ b/Tests/Fixtures/TestProject/SyncedFolder/ExcludedFile.swift @@ -0,0 +1 @@ +// excluded diff --git a/Tests/Fixtures/TestProject/SyncedFolder/Info.plist b/Tests/Fixtures/TestProject/SyncedFolder/Info.plist new file mode 100644 index 00000000..0c67376e --- /dev/null +++ b/Tests/Fixtures/TestProject/SyncedFolder/Info.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/Tests/Fixtures/TestProject/project.yml b/Tests/Fixtures/TestProject/project.yml index 801d4275..599da8e2 100644 --- a/Tests/Fixtures/TestProject/project.yml +++ b/Tests/Fixtures/TestProject/project.yml @@ -166,6 +166,9 @@ targets: - String Catalogs/LocalizableStrings.xcstrings - path: SyncedFolder type: syncedFolder + excludes: + - ExcludedFile.swift + - Info.plist settings: INFOPLIST_FILE: App_iOS/Info.plist PRODUCT_BUNDLE_IDENTIFIER: com.project.app diff --git a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift index 3712f42e..55f896b5 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -120,6 +120,99 @@ class SourceGeneratorTests: XCTestCase { try expect([syncedFolder]) == pbxProj.nativeTargets.first?.fileSystemSynchronizedGroups } + $0.it("adds excludes as membership exceptions for synced folder") { + let directories = """ + Sources: + - a.swift + - b.swift + - Generated: + - c.generated.swift + - d.generated.swift + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", excludes: ["b.swift", "Generated/*.generated.swift"], type: .syncedFolder) + let target = Target(name: "Test", type: .application, platform: .iOS, sources: [source]) + let project = Project(basePath: directoryPath, name: "Test", targets: [target]) + + let pbxProj = try project.generatePbxProj() + let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup } + let syncedFolder = try unwrap(syncedFolders.first) + + let exceptionSets = syncedFolder.exceptions?.compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet } + let exceptionSet = try unwrap(exceptionSets?.first) + let exceptions = try unwrap(exceptionSet.membershipExceptions) + + try expect(exceptions.contains("b.swift")) == true + try expect(exceptions.contains("Generated/c.generated.swift")) == true + try expect(exceptions.contains("Generated/d.generated.swift")) == true + try expect(exceptions.contains("a.swift")) == false + } + + $0.it("auto-excludes Info.plist from synced folder membership") { + let directories = """ + Sources: + - a.swift + - Info.plist + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", type: .syncedFolder) + let target = Target( + name: "Test", + type: .application, + platform: .iOS, + settings: try Settings(jsonDictionary: ["INFOPLIST_FILE": "Sources/Info.plist"]), + sources: [source] + ) + let project = Project(basePath: directoryPath, name: "Test", targets: [target]) + + let pbxProj = try project.generatePbxProj() + let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup } + let syncedFolder = try unwrap(syncedFolders.first) + + let exceptionSets = syncedFolder.exceptions?.compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet } + let exceptionSet = try unwrap(exceptionSets?.first) + let exceptions = try unwrap(exceptionSet.membershipExceptions) + + try expect(exceptions.contains("Info.plist")) == true + } + + $0.it("creates no exception set for synced folder without excludes") { + let directories = """ + Sources: + - a.swift + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", type: .syncedFolder) + let target = Target(name: "Test", type: .application, platform: .iOS, sources: [source]) + let project = Project(basePath: directoryPath, name: "Test", targets: [target]) + + let pbxProj = try project.generatePbxProj() + let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup } + let syncedFolder = try unwrap(syncedFolders.first) + + try expect(syncedFolder.exceptions?.isEmpty ?? true) == true + } + + $0.it("adds empty resources build phase for synced folder") { + let directories = """ + Sources: + - a.swift + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", type: .syncedFolder) + let target = Target(name: "Test", type: .application, platform: .iOS, sources: [source]) + let project = Project(basePath: directoryPath, name: "Test", targets: [target]) + + let pbxProj = try project.generatePbxProj() + let nativeTarget = try unwrap(pbxProj.nativeTargets.first) + let hasResourcesPhase = nativeTarget.buildPhases.contains { $0 is PBXResourcesBuildPhase } + try expect(hasResourcesPhase) == true + } + $0.it("supports frameworks in sources") { let directories = """ Sources: