From 391bd18be296562847c3e6fba8310981066da626 Mon Sep 17 00:00:00 2001 From: Alexandre Nussbaumer Date: Wed, 11 Mar 2026 11:21:32 +0100 Subject: [PATCH 1/3] Fix configFiles creating duplicate group inside synced folder When configFiles reference paths inside a synced folder source, getContainedFileReference() created a separate PBXGroup hierarchy that duplicated the PBXFileSystemSynchronizedRootGroup already managing those files. Skip group creation in getContainedFileReference when the file path falls inside an existing synced folder root. Fixes the same class of issue as #1602, but for configFiles rather than target sources. --- Sources/XcodeGenKit/SourceGenerator.swift | 18 ++++++++++++ .../SourceGeneratorTests.swift | 28 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/Sources/XcodeGenKit/SourceGenerator.swift b/Sources/XcodeGenKit/SourceGenerator.swift index b4f74c53..eb271d00 100644 --- a/Sources/XcodeGenKit/SourceGenerator.swift +++ b/Sources/XcodeGenKit/SourceGenerator.swift @@ -201,6 +201,11 @@ class SourceGenerator { let parentPath = path.parent() let fileReference = getFileReference(path: path, inPath: parentPath) + + guard !isInsideSyncedFolder(path: path) else { + return fileReference + } + let parentGroup = getGroup( path: parentPath, mergingChildren: [fileReference], @@ -277,6 +282,19 @@ class SourceGenerator { } } + /// Whether the given path falls inside a target source configured as a synced folder. + /// Checks the project spec directly because configFiles are resolved before target sources + /// populate `syncedGroupsByPath`. + private func isInsideSyncedFolder(path: Path) -> Bool { + let relativePath = (try? path.relativePath(from: project.basePath)) ?? path + return project.targets.contains { target in + target.sources.contains { source in + let type = source.type ?? (project.options.defaultSourceDirectoryType ?? .group) + return type == .syncedFolder && relativePath.string.hasPrefix(source.path + "/") + } + } + } + /// returns a default build phase for a given path. This is based off the filename private func getDefaultBuildPhase(for path: Path, targetType: PBXProductType) -> BuildPhaseSpec? { if let buildPhase = getFileType(path: path)?.buildPhase { diff --git a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift index be430a1a..64d852cd 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -491,6 +491,34 @@ class SourceGeneratorTests: XCTestCase { try expect(appGroup === testsGroup) == true } + $0.it("does not create duplicate group for configFiles inside synced folder") { + let directories = """ + Sources: + - a.swift + - Config: + - config.xcconfig + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", type: .syncedFolder) + let target = Target(name: "Target1", type: .application, platform: .iOS, sources: [source]) + let project = Project( + basePath: directoryPath, + name: "Test", + targets: [target], + configFiles: ["Debug": "Sources/Config/config.xcconfig"] + ) + + let pbxProj = try project.generatePbxProj() + let mainGroup = try pbxProj.getMainGroup() + + let sourcesChildren = mainGroup.children.filter { $0.path == "Sources" || $0.name == "Sources" } + try expect(sourcesChildren.count) == 1 + + let syncedFolders = mainGroup.children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup } + try expect(syncedFolders.count) == 1 + } + $0.it("supports frameworks in sources") { let directories = """ Sources: From 494fc9e1ce97222f1055c7b8c74d6febf8429564 Mon Sep 17 00:00:00 2001 From: Alexandre Nussbaumer Date: Wed, 11 Mar 2026 16:39:23 +0100 Subject: [PATCH 2/3] Fix synced folder group duplication and directory-level membershipExceptions - Prevent rootGroups insertion for paths inside synced folders, avoiding duplicate PBXGroup alongside PBXFileSystemSynchronizedRootGroup - Recurse into non-included directories in findExceptions to list individual file paths instead of directory names, since Xcode does not recursively exclude directory contents from membershipExceptions Co-Authored-By: Claude Opus 4.6 --- Sources/XcodeGenKit/SourceGenerator.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/XcodeGenKit/SourceGenerator.swift b/Sources/XcodeGenKit/SourceGenerator.swift index 0cc48d79..cba17c6b 100644 --- a/Sources/XcodeGenKit/SourceGenerator.swift +++ b/Sources/XcodeGenKit/SourceGenerator.swift @@ -200,12 +200,13 @@ class SourceGenerator { let createIntermediateGroups = project.options.createIntermediateGroups let parentPath = path.parent() - let fileReference = getFileReference(path: path, inPath: parentPath) guard !isInsideSyncedFolder(path: path) else { - return fileReference + return getFileReference(path: path, inPath: project.basePath, sourceTree: .sourceRoot) } + let fileReference = getFileReference(path: path, inPath: parentPath) + let parentGroup = getGroup( path: parentPath, mergingChildren: [fileReference], @@ -368,7 +369,7 @@ class SourceGenerator { groupReference = addObject(group) groupsByPath[path] = groupReference - if isTopLevelGroup { + if isTopLevelGroup && !isInsideSyncedFolder(path: path) { rootGroups.insert(groupReference) } } @@ -414,6 +415,8 @@ class SourceGenerator { if child.isDirectory && !Xcode.isDirectoryFileWrapper(path: child) { findExceptions(in: child) } + } else if child.isDirectory && !Xcode.isDirectoryFileWrapper(path: child) { + findExceptions(in: child) } else { exceptions.insert(child) } From cf852704af997b3e7d18cd43e7692e008a6c3d0f Mon Sep 17 00:00:00 2001 From: Alexandre Nussbaumer Date: Wed, 11 Mar 2026 16:47:33 +0100 Subject: [PATCH 3/3] Update test: expect individual file paths instead of directory names in exceptions Xcode does not recursively exclude directory contents from membershipExceptions, so the correct behavior is to list individual files rather than directory names. Co-Authored-By: Claude Opus 4.6 --- Tests/XcodeGenKitTests/SourceGeneratorTests.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift index 041834ac..2fd5a622 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -415,7 +415,7 @@ class SourceGeneratorTests: XCTestCase { try expect(exceptions.contains("Nested/b.swift")) == false } - $0.it("excludes entire subdirectory as single exception when no files in it are included") { + $0.it("excludes individual files in subdirectory when no files in it are included") { let directories = """ Sources: - a.swift @@ -436,10 +436,11 @@ class SourceGeneratorTests: XCTestCase { let exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet) let exceptions = try unwrap(exceptionSet.membershipExceptions) - // The whole directory should be a single exception entry, not each file within it - try expect(exceptions.contains("ExcludedDir")) == true - try expect(exceptions.contains("ExcludedDir/x.swift")) == false - try expect(exceptions.contains("ExcludedDir/y.swift")) == false + // Xcode does not recursively exclude directory contents from membershipExceptions, + // so individual files must be listed instead of the directory name + try expect(exceptions.contains("ExcludedDir")) == false + try expect(exceptions.contains("ExcludedDir/x.swift")) == true + try expect(exceptions.contains("ExcludedDir/y.swift")) == true try expect(exceptions.contains("a.swift")) == false }