From 06ae8b1ed6cc0f867928a58b2bdee64b87473b7b Mon Sep 17 00:00:00 2001 From: Max Seelemann Date: Thu, 26 Feb 2026 21:26:54 +0100 Subject: [PATCH 1/5] Add explicitFolders support to syncedFolder Adds an `explicitFolders` property to `TargetSource` that is expanded from Glob patterns and passed through to `PBXFileSystemSynchronizedRootGroup`. --- Docs/ProjectSpec.md | 1 + Sources/ProjectSpec/TargetSource.swift | 5 ++++ Sources/XcodeGenKit/SourceGenerator.swift | 17 ++++++++++- .../Project.xcodeproj/project.pbxproj | 3 ++ .../FeatureATests/__Snapshots__/.gitkeep | 0 .../FeatureBTests/__Snapshots__/.gitkeep | 0 .../SyncedFolder/Resources/.gitkeep | 0 Tests/Fixtures/TestProject/project.yml | 3 ++ .../SourceGeneratorTests.swift | 28 +++++++++++++++++++ 9 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 Tests/Fixtures/TestProject/SyncedFolder/FeatureATests/__Snapshots__/.gitkeep create mode 100644 Tests/Fixtures/TestProject/SyncedFolder/FeatureBTests/__Snapshots__/.gitkeep create mode 100644 Tests/Fixtures/TestProject/SyncedFolder/Resources/.gitkeep diff --git a/Docs/ProjectSpec.md b/Docs/ProjectSpec.md index c1a6259ca..3f091bfd6 100644 --- a/Docs/ProjectSpec.md +++ b/Docs/ProjectSpec.md @@ -520,6 +520,7 @@ A source can be provided via a string (the path) or an object of the form: - [ ] **compilerFlags**: **[String]** or **String** - A list of compilerFlags to add to files under this specific path provided as a list or a space delimited string. Defaults to empty. - [ ] **excludes**: **[String]** - A list of [global patterns](https://en.wikipedia.org/wiki/Glob_(programming)) representing the files to exclude. These rules are relative to `path` and _not the directory where `project.yml` resides_. XcodeGen uses Bash 4's Glob behaviors where globstar (**) is enabled. - [ ] **includes**: **[String]** - A list of global patterns in the same format as `excludes` representing the files to include. These rules are relative to `path` and _not the directory where `project.yml` resides_. If **excludes** is present and file conflicts with **includes**, **excludes** will override the **includes** behavior. +- [ ] **explicitFolders**: **[String]** - Only valid for `syncedFolder` type. A list of global patterns in the same format as `excludes` to child folders that Xcode should treat as folder references. - [ ] **destinationFilters**: **[[Supported Destinations](#supported-destinations)]** - List of supported platform destinations the files should filter to. Defaults to all supported destinations. - [ ] **inferDestinationFiltersByPath**: **Bool** - This is a convenience filter that helps you to filter the files if their paths match these patterns `**//*` or `*_.swift`. Note, if you use `destinationFilters` this flag will be ignored. - [ ] **createIntermediateGroups**: **Bool** - This overrides the value in [Options](#options). diff --git a/Sources/ProjectSpec/TargetSource.swift b/Sources/ProjectSpec/TargetSource.swift index f5932c837..6cfe7e7cc 100644 --- a/Sources/ProjectSpec/TargetSource.swift +++ b/Sources/ProjectSpec/TargetSource.swift @@ -16,6 +16,7 @@ public struct TargetSource: Equatable { public var compilerFlags: [String] public var excludes: [String] public var includes: [String] + public var explicitFolders: [String] public var type: SourceType? public var optional: Bool public var buildPhase: BuildPhaseSpec? @@ -47,6 +48,7 @@ public struct TargetSource: Equatable { compilerFlags: [String] = [], excludes: [String] = [], includes: [String] = [], + explicitFolders: [String] = [], type: SourceType? = nil, optional: Bool = optionalDefault, buildPhase: BuildPhaseSpec? = nil, @@ -63,6 +65,7 @@ public struct TargetSource: Equatable { self.compilerFlags = compilerFlags self.excludes = excludes self.includes = includes + self.explicitFolders = explicitFolders self.type = type self.optional = optional self.buildPhase = buildPhase @@ -106,6 +109,7 @@ extension TargetSource: JSONObjectConvertible { headerVisibility = jsonDictionary.json(atKeyPath: "headerVisibility") excludes = jsonDictionary.json(atKeyPath: "excludes") ?? [] includes = jsonDictionary.json(atKeyPath: "includes") ?? [] + explicitFolders = jsonDictionary.json(atKeyPath: "explicitFolders") ?? [] type = jsonDictionary.json(atKeyPath: "type") optional = jsonDictionary.json(atKeyPath: "optional") ?? TargetSource.optionalDefault @@ -133,6 +137,7 @@ extension TargetSource: JSONEncodable { "compilerFlags": compilerFlags, "excludes": excludes, "includes": includes, + "explicitFolders": explicitFolders, "name": name, "group": group, "headerVisibility": headerVisibility?.rawValue, diff --git a/Sources/XcodeGenKit/SourceGenerator.swift b/Sources/XcodeGenKit/SourceGenerator.swift index 06486f113..1879f5c14 100644 --- a/Sources/XcodeGenKit/SourceGenerator.swift +++ b/Sources/XcodeGenKit/SourceGenerator.swift @@ -399,6 +399,20 @@ class SourceGenerator { ) } + /// Expands glob patterns in `explicitFolders` relative to the synced root path. + private func resolveExplicitFolders(targetSource: TargetSource) -> [String] { + let rootSourcePath = project.basePath + targetSource.path + + return targetSource.explicitFolders.flatMap { pattern in + let matches = Glob(pattern: "\(rootSourcePath)/\(pattern)") + .map { Path($0) } + .filter { $0.isDirectory } + .compactMap { try? $0.relativePath(from: rootSourcePath).string } + .sorted() + return matches.isEmpty ? [pattern] : matches + } + } + /// Checks whether the path is not in any default or TargetSource excludes func isIncludedPath(_ path: Path, excludePaths: Set, includePaths: SortedArray?) -> Bool { return !defaultExcludedFiles.contains(where: { path.lastComponent == $0 }) @@ -695,6 +709,7 @@ class SourceGenerator { case .syncedFolder: let relativePath = (try? path.relativePath(from: project.basePath)) ?? path + let resolvedExplicitFolders = resolveExplicitFolders(targetSource: targetSource) let syncedRootGroup = PBXFileSystemSynchronizedRootGroup( sourceTree: .group, @@ -702,7 +717,7 @@ class SourceGenerator { name: targetSource.name, explicitFileTypes: [:], exceptions: [], - explicitFolders: [] + explicitFolders: resolvedExplicitFolders ) addObject(syncedRootGroup) sourceReference = syncedRootGroup diff --git a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj index eabdb4b1d..c94095ff6 100644 --- a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj +++ b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj @@ -850,6 +850,9 @@ explicitFileTypes = { }; explicitFolders = ( + Resources, + FeatureATests, + FeatureBTests, ); path = SyncedFolder; sourceTree = ""; diff --git a/Tests/Fixtures/TestProject/SyncedFolder/FeatureATests/__Snapshots__/.gitkeep b/Tests/Fixtures/TestProject/SyncedFolder/FeatureATests/__Snapshots__/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/Tests/Fixtures/TestProject/SyncedFolder/FeatureBTests/__Snapshots__/.gitkeep b/Tests/Fixtures/TestProject/SyncedFolder/FeatureBTests/__Snapshots__/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/Tests/Fixtures/TestProject/SyncedFolder/Resources/.gitkeep b/Tests/Fixtures/TestProject/SyncedFolder/Resources/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/Tests/Fixtures/TestProject/project.yml b/Tests/Fixtures/TestProject/project.yml index 599da8e23..97e5af98e 100644 --- a/Tests/Fixtures/TestProject/project.yml +++ b/Tests/Fixtures/TestProject/project.yml @@ -169,6 +169,9 @@ targets: excludes: - ExcludedFile.swift - Info.plist + explicitFolders: + - Resources + - "**/*Tests" 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 55f896b55..c2591d91e 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -102,6 +102,34 @@ class SourceGeneratorTests: XCTestCase { try expect([syncedFolder]) == pbxProj.nativeTargets.first?.fileSystemSynchronizedGroups } + $0.it("generates synced folder with explicitFolders") { + let directories = """ + Sources: + Images: + - image.png + MainSuite: + FeatureATests: + - __Snapshots__: + - snap.png + FeatureBTests: + - __Snapshots__: + - snap.png + NotATest: + - file.swift + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", explicitFolders: ["Images", "**/*Tests"], 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.explicitFolders) == ["Images", "MainSuite/FeatureATests", "MainSuite/FeatureBTests"] + } + $0.it("respects defaultSourceDirectoryType") { let directories = """ Sources: From 59f2c9e7179828315ff63a0f9d002c71d310fcb1 Mon Sep 17 00:00:00 2001 From: Max Seelemann Date: Thu, 26 Feb 2026 23:51:18 +0100 Subject: [PATCH 2/5] Fix syncedFolder sources ignoring createIntermediateGroups When createIntermediateGroups was enabled and a syncedFolder source had a multi-component path (e.g. SyncedParent/SyncedChild), two things went wrong: 1. The synced folder was unconditionally added to rootGroups, causing it to appear both at the project root and inside the correct intermediate parent group. 2. The synced folder kept its full project-relative path instead of being made relative to its parent group, so Xcode concatenated them into a wrong path (e.g. SyncedParent/SyncedParent/SyncedChild). --- Sources/XcodeGenKit/SourceGenerator.swift | 6 ++- .../Project.xcodeproj/project.pbxproj | 44 ++++++++++++++----- .../SyncedChild/SyncedChildFile.swift | 1 + Tests/Fixtures/TestProject/project.yml | 3 ++ .../SourceGeneratorTests.swift | 26 +++++++++++ 5 files changed, 66 insertions(+), 14 deletions(-) create mode 100644 Tests/Fixtures/TestProject/SyncedParent/SyncedChild/SyncedChildFile.swift diff --git a/Sources/XcodeGenKit/SourceGenerator.swift b/Sources/XcodeGenKit/SourceGenerator.swift index 1879f5c14..82e100ef7 100644 --- a/Sources/XcodeGenKit/SourceGenerator.swift +++ b/Sources/XcodeGenKit/SourceGenerator.swift @@ -722,8 +722,9 @@ class SourceGenerator { addObject(syncedRootGroup) sourceReference = syncedRootGroup - // TODO: adjust if hasCustomParent == true - rootGroups.insert(syncedRootGroup) + if !(createIntermediateGroups || hasCustomParent) || path.parent() == project.basePath { + rootGroups.insert(syncedRootGroup) + } let sourceFile = generateSourceFile( targetType: targetType, @@ -740,6 +741,7 @@ class SourceGenerator { try makePathRelative(for: sourceReference, at: path) } else if createIntermediateGroups { createIntermediaGroups(for: sourceReference, at: sourcePath) + try makePathRelative(for: sourceReference, at: sourcePath) } return sourceFiles diff --git a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj index c94095ff6..d49a058e9 100644 --- a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj +++ b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj @@ -842,6 +842,16 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + A2F1B5386E15A261AC8A4DEE /* SyncedChild */ = { + isa = PBXFileSystemSynchronizedRootGroup; + explicitFileTypes = { + }; + explicitFolders = ( + ); + name = SyncedChild; + path = SyncedChild; + sourceTree = ""; + }; AE2AB2772F70DFFF402AA02B /* SyncedFolder */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -1015,6 +1025,12 @@ isa = PBXGroup; children = ( 2F80635127D17ECB7748067B /* FolderWithDot2.0 */, + CE1F06D99242F4223D081F0D /* LaunchScreen.storyboard */, + 9E17D598D98065767A04740F /* Localizable.strings */, + 65C8D6D1DDC1512D396C07B7 /* Localizable.stringsdict */, + 0C6BA0D12467A13EC012C728 /* LocalizedStoryboard.storyboard */, + 814D72C2B921F60B759C2D4B /* Main.storyboard */, + 306796628DD52FA55E833B65 /* Model.xcdatamodeld */, AEBCA8CFF769189C0D52031E /* App_iOS.xctestplan */, F0D48A913C087D049C8EDDD7 /* App.entitlements */, 7F1A2F579A6F79C62DDA0571 /* AppDelegate.swift */, @@ -1023,12 +1039,6 @@ B5C943D39DD7812CAB94B614 /* Documentation.docc */, C9DDE1B06BCC1CDE0ECF1589 /* Info.plist */, AAA49985DFFE797EE8416887 /* inputList.xcfilelist */, - CE1F06D99242F4223D081F0D /* LaunchScreen.storyboard */, - 9E17D598D98065767A04740F /* Localizable.strings */, - 65C8D6D1DDC1512D396C07B7 /* Localizable.stringsdict */, - 0C6BA0D12467A13EC012C728 /* LocalizedStoryboard.storyboard */, - 814D72C2B921F60B759C2D4B /* Main.storyboard */, - 306796628DD52FA55E833B65 /* Model.xcdatamodeld */, BF59AC868D227C92CA8B1B57 /* Model.xcmappingmodel */, C7809CE9FE9852C2AA87ACE5 /* module.modulemap */, 553D289724905857912C7A1D /* outputList.xcfilelist */, @@ -1071,6 +1081,8 @@ BDA839814AF73F01F7710518 /* StaticLibrary_ObjC */, CBDAC144248EE9D3838C6AAA /* StaticLibrary_Swift */, 6E0D17C5B4E6F01B89254309 /* String Catalogs */, + AE2AB2772F70DFFF402AA02B /* SyncedFolder */, + AB527E0D553CE53AF54C39CD /* SyncedParent */, 8CFD8AD4820FAB9265663F92 /* Tool */, 4C7F5EB7D6F3E0E9B426AB4A /* Utilities */, 3FEA12CF227D41EF50E5C2DB /* Vendor */, @@ -1079,7 +1091,6 @@ 2E1E747C7BC434ADB80CC269 /* Headers */, 6B1603BA83AA0C7B94E45168 /* ResourceFolder */, 6BBE762F36D94AB6FFBFE834 /* SomeFile */, - AE2AB2772F70DFFF402AA02B /* SyncedFolder */, 79DC4A1E4D2E0D3A215179BC /* Bundles */, FC1515684236259C50A7747F /* Frameworks */, AC523591AC7BE9275003D2DB /* Products */, @@ -1320,6 +1331,14 @@ path = iMessageApp; sourceTree = ""; }; + AB527E0D553CE53AF54C39CD /* SyncedParent */ = { + isa = PBXGroup; + children = ( + A2F1B5386E15A261AC8A4DEE /* SyncedChild */, + ); + path = SyncedParent; + sourceTree = ""; + }; AC523591AC7BE9275003D2DB /* Products */ = { isa = PBXGroup; children = ( @@ -1371,9 +1390,9 @@ BAE6C12745737019DC9E98BF /* App_watchOS */ = { isa = PBXGroup; children = ( + C872631362DDBAFCE71E5C66 /* Interface.storyboard */, D8A016580A3B8F72B820BFBF /* Assets.xcassets */, FED40A89162E446494DDE7C7 /* Info.plist */, - C872631362DDBAFCE71E5C66 /* Interface.storyboard */, ); path = App_watchOS; sourceTree = ""; @@ -1391,9 +1410,9 @@ BF58996786F85CB77BEE72EF /* iMessageExtension */ = { isa = PBXGroup; children = ( + 753001CDCEAA4C4E1AFF8E87 /* MainInterface.storyboard */, 1BC32A813B80A53962A1F365 /* Assets.xcassets */, 40863AE6202CFCD0529D8438 /* Info.plist */, - 753001CDCEAA4C4E1AFF8E87 /* MainInterface.storyboard */, B198242976C3395E31FE000A /* MessagesViewController.swift */, ); path = iMessageExtension; @@ -1402,12 +1421,12 @@ C81493FAD71E9A9A19E00AD5 /* App_Clip */ = { isa = PBXGroup; children = ( + 79325B44B19B83EC6CEDBCC5 /* LaunchScreen.storyboard */, + 2FC2A8A829CE71B1CF415FF7 /* Main.storyboard */, 23A2F16890ECF2EE3FED72AE /* AppDelegate.swift */, 59DA55A04FA2366B5D0BEEFF /* Assets.xcassets */, 1FA5E208EC184E3030D2A21D /* Clip.entitlements */, 6F165CDD5BCC13AFF50B65E2 /* Info.plist */, - 79325B44B19B83EC6CEDBCC5 /* LaunchScreen.storyboard */, - 2FC2A8A829CE71B1CF415FF7 /* Main.storyboard */, DFE6A6FAAFF701FE729293DE /* ViewController.swift */, ); path = App_Clip; @@ -1452,10 +1471,10 @@ EE78B4FBD0137D1975C47D76 /* App_macOS */ = { isa = PBXGroup; children = ( + 74FBDFA5CB063F6001AD8ACD /* Main.storyboard */, 9528528C989D24FE3E6C533E /* App-Info.plist */, 09B82F603D981398F38D762E /* AppDelegate.swift */, E55F45EACB0F382722D61C8D /* Assets.xcassets */, - 74FBDFA5CB063F6001AD8ACD /* Main.storyboard */, A4C3FE6B986506724DAB5D0F /* ViewController.swift */, ); path = App_macOS; @@ -1717,6 +1736,7 @@ 981D116D40DBA0407D0E0E94 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( + A2F1B5386E15A261AC8A4DEE /* SyncedChild */, AE2AB2772F70DFFF402AA02B /* SyncedFolder */, ); name = App_iOS; diff --git a/Tests/Fixtures/TestProject/SyncedParent/SyncedChild/SyncedChildFile.swift b/Tests/Fixtures/TestProject/SyncedParent/SyncedChild/SyncedChildFile.swift new file mode 100644 index 000000000..fecc4ab44 --- /dev/null +++ b/Tests/Fixtures/TestProject/SyncedParent/SyncedChild/SyncedChildFile.swift @@ -0,0 +1 @@ +import Foundation diff --git a/Tests/Fixtures/TestProject/project.yml b/Tests/Fixtures/TestProject/project.yml index 97e5af98e..dbd4c317f 100644 --- a/Tests/Fixtures/TestProject/project.yml +++ b/Tests/Fixtures/TestProject/project.yml @@ -172,6 +172,9 @@ targets: explicitFolders: - Resources - "**/*Tests" + - path: SyncedParent/SyncedChild + type: syncedFolder + createIntermediateGroups: true 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 c2591d91e..31770a6e6 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -130,6 +130,32 @@ class SourceGeneratorTests: XCTestCase { try expect(syncedFolder.explicitFolders) == ["Images", "MainSuite/FeatureATests", "MainSuite/FeatureBTests"] } + $0.it("generates synced folder with createIntermediateGroups") { + let directories = """ + Parent: + Child: + - a.swift + """ + try createDirectories(directories) + + let target = Target(name: "Test", type: .application, platform: .iOS, sources: [.init(path: "Parent/Child", type: .syncedFolder)]) + let options = SpecOptions(createIntermediateGroups: true) + let project = Project(basePath: directoryPath, name: "Test", targets: [target], options: options) + + let pbxProj = try project.generatePbxProj() + let mainGroup = try pbxProj.getMainGroup() + + let rootSyncedFolders = mainGroup.children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup } + try expect(rootSyncedFolders.count) == 0 + + let parentGroup = try unwrap(mainGroup.children.compactMap({ $0 as? PBXGroup }).first(where: { $0.nameOrPath == "Parent" })) + let nestedSyncedFolders = parentGroup.children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup } + let syncedFolder = try unwrap(nestedSyncedFolders.first) + + try expect(syncedFolder.path) == "Child" + try expect([syncedFolder]) == pbxProj.nativeTargets.first?.fileSystemSynchronizedGroups + } + $0.it("respects defaultSourceDirectoryType") { let directories = """ Sources: From 458bc314dc2c49a115cde5cee91f6439d3ebfb4f Mon Sep 17 00:00:00 2001 From: Max Seelemann Date: Fri, 27 Feb 2026 23:51:29 +0100 Subject: [PATCH 3/5] Enhance PBXFileElement to recognize synced folders as groups that can be sorted --- Sources/XcodeGenKit/PBXProjGenerator.swift | 6 +-- .../PBXProjGeneratorTests.swift | 45 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/Sources/XcodeGenKit/PBXProjGenerator.swift b/Sources/XcodeGenKit/PBXProjGenerator.swift index b8ab9d371..a7fefd224 100644 --- a/Sources/XcodeGenKit/PBXProjGenerator.swift +++ b/Sources/XcodeGenKit/PBXProjGenerator.swift @@ -1692,13 +1692,13 @@ extension Platform { } extension PBXFileElement { - /// - returns: `true` if the element is a group or a folder reference. Likely an SPM package. + /// - returns: `true` if the element is a group, a folder reference (likely an SPM package), or a synced folder. var isGroupOrFolder: Bool { - self is PBXGroup || (self as? PBXFileReference)?.lastKnownFileType == "folder" + self is PBXGroup || self is PBXFileSystemSynchronizedRootGroup || (self as? PBXFileReference)?.lastKnownFileType == "folder" } public func getSortOrder(groupSortPosition: SpecOptions.GroupSortPosition) -> Int { - if type(of: self).isa == "PBXGroup" { + if self is PBXGroup || self is PBXFileSystemSynchronizedRootGroup { switch groupSortPosition { case .top: return -1 case .bottom: return 1 diff --git a/Tests/XcodeGenKitTests/PBXProjGeneratorTests.swift b/Tests/XcodeGenKitTests/PBXProjGeneratorTests.swift index 3af4148d8..232995c83 100644 --- a/Tests/XcodeGenKitTests/PBXProjGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/PBXProjGeneratorTests.swift @@ -349,6 +349,51 @@ class PBXProjGeneratorTests: XCTestCase { try expect(packages) == ["FeatureA", "FeatureB", "Common"] } + + $0.it("sorts synced folders alongside groups") { + var options = SpecOptions() + options.groupSortPosition = .top + options.groupOrdering = [ + GroupOrdering( + order: [ + "Sources", + "SyncedSources", + "Resources", + ] + ), + ] + + let directories = """ + Resources: + - file.swift + Sources: + - file.swift + SyncedSources: + - file.swift + """ + try createDirectories(directories) + + let target = Target( + name: "Test", + type: .application, + platform: .iOS, + sources: [ + "Sources", + .init(path: "SyncedSources", type: .syncedFolder), + "Resources", + ] + ) + let project = Project(basePath: directoryPath, name: "Test", targets: [target], options: options) + let projGenerator = PBXProjGenerator(project: project) + + let pbxProj = try project.generatePbxProj() + let group = try pbxProj.getMainGroup() + + projGenerator.setupGroupOrdering(group: group) + + let mainGroups = group.children.map { $0.nameOrPath } + try expect(mainGroups) == ["Sources", "SyncedSources", "Resources", "Products"] + } } } From f80f941debcc202633532fd1d9d458191cd96278 Mon Sep 17 00:00:00 2001 From: Max Seelemann Date: Mon, 2 Mar 2026 14:25:55 +0100 Subject: [PATCH 4/5] Fix membership exceptions for nested synced folder with intermediate groups --- Sources/XcodeGenKit/PBXProjGenerator.swift | 25 ++++++++++--------- .../SourceGeneratorTests.swift | 23 +++++++++++++++++ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/Sources/XcodeGenKit/PBXProjGenerator.swift b/Sources/XcodeGenKit/PBXProjGenerator.swift index a7fefd224..0c3a99d74 100644 --- a/Sources/XcodeGenKit/PBXProjGenerator.swift +++ b/Sources/XcodeGenKit/PBXProjGenerator.swift @@ -1459,29 +1459,30 @@ public class PBXProjGenerator { } // add fileSystemSynchronizedGroups - let synchronizedRootGroups = sourceFiles.compactMap { $0.fileReference as? PBXFileSystemSynchronizedRootGroup } + let synchronizedRootGroups: [PBXFileSystemSynchronizedRootGroup] = sourceFiles.compactMap { sourceFile in + guard let syncedGroup = sourceFile.fileReference as? PBXFileSystemSynchronizedRootGroup else { return nil } + + configureMembershipExceptions( + for: syncedGroup, + path: sourceFile.path, + target: target, + targetObject: targetObject, + infoPlistFiles: infoPlistFiles + ) + return syncedGroup + } 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, + path syncedPath: Path, 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 } diff --git a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift index 31770a6e6..be7eb005f 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -203,6 +203,29 @@ class SourceGeneratorTests: XCTestCase { try expect(exceptions.contains("a.swift")) == false } + $0.it("adds membership exceptions for nested synced folder with intermediate groups") { + let directories = """ + Sources: + Nested: + - a.swift + - b.swift + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources/Nested", excludes: ["b.swift"], type: .syncedFolder) + let target = Target(name: "Test", type: .application, platform: .iOS, sources: [source]) + let project = Project(basePath: directoryPath, name: "Test", targets: [target], options: .init(createIntermediateGroups: true)) + + let pbxProj = try project.generatePbxProj() + let sourcesGroup = try unwrap(try pbxProj.getMainGroup().children.first { $0.nameOrPath == "Sources" } as? PBXGroup) + let syncedFolder = try unwrap(sourcesGroup.children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }.first) + + let exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet) + let exceptions = try unwrap(exceptionSet.membershipExceptions) + + try expect(exceptions) == ["b.swift"] + } + $0.it("auto-excludes Info.plist from synced folder membership") { let directories = """ Sources: From c89ca141fc8f228711e96b337b5901507eef8018 Mon Sep 17 00:00:00 2001 From: Max Seelemann Date: Tue, 3 Mar 2026 10:09:06 +0100 Subject: [PATCH 5/5] Update Changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc18bf44d..f1e4d35e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Next Version +- Adds an `explicitFolders` property to `TargetSource` that is passed through to `PBXFileSystemSynchronizedRootGroup`, to turn entire subfolders into Resources. #1596 @macguru +- Allow synced folders to be sorted using `groupOrdering`. #1596 @macguru +- Fixed synced folders ignoring `createIntermediateGroups=YES` and always beging created at the root level. #1596 @macguru +- Fix membership exceptions not working for nested synced folders with intermediate groups enabled. #1596 @macguru + ## 2.44.1 ### Fixed