Fix issue 1602 - Synced folders: includes silently ignored + no deduplication across targets (#1604)

* Fix issue 1602 - Synced folders: includes silently ignored + no deduplication across targets

* Add more tests
This commit is contained in:
Bruno Coelho
2026-03-09 23:25:07 +00:00
committed by GitHub
parent 311483139c
commit acd366f089
3 changed files with 250 additions and 10 deletions
+1 -1
View File
@@ -1489,7 +1489,7 @@ public class PBXProjGenerator {
}) else { return }
var exceptions: Set<String> = Set(
sourceGenerator.expandedExcludes(for: targetSource)
sourceGenerator.syncedFolderExceptions(for: targetSource, at: syncedPath)
.compactMap { try? $0.relativePath(from: syncedPath).string }
)
+48 -9
View File
@@ -18,6 +18,7 @@ class SourceGenerator {
private var fileReferencesByPath: [String: PBXFileElement] = [:]
private var groupsByPath: [Path: PBXGroup] = [:]
private var variantGroupsByPath: [Path: PBXVariantGroup] = [:]
private var syncedGroupsByPath: [String: PBXFileSystemSynchronizedRootGroup] = [:]
private let project: Project
let pbxProj: PBXProj
@@ -377,6 +378,34 @@ class SourceGenerator {
getSourceMatches(targetSource: targetSource, patterns: targetSource.excludes)
}
/// Returns the expanded set of exception paths for a synced folder, including excludes and non-included files.
func syncedFolderExceptions(for targetSource: TargetSource, at syncedPath: Path) -> Set<Path> {
let excludePaths = expandedExcludes(for: targetSource)
if targetSource.includes.isEmpty {
return excludePaths
}
let includePaths = SortedArray(getSourceMatches(targetSource: targetSource, patterns: targetSource.includes))
var exceptions: Set<Path> = []
func findExceptions(in path: Path) {
guard let children = try? path.children() else { return }
for child in children {
if isIncludedPath(child, excludePaths: excludePaths, includePaths: includePaths) {
if child.isDirectory && !Xcode.isDirectoryFileWrapper(path: child) {
findExceptions(in: child)
}
} else {
exceptions.insert(child)
}
}
}
findExceptions(in: syncedPath)
return exceptions
}
/// Collects all the excluded paths within the targetSource
private func getSourceMatches(targetSource: TargetSource, patterns: [String]) -> Set<Path> {
let rootSourcePath = project.basePath + targetSource.path
@@ -711,15 +740,25 @@ class SourceGenerator {
let relativePath = (try? path.relativePath(from: project.basePath)) ?? path
let resolvedExplicitFolders = resolveExplicitFolders(targetSource: targetSource)
let syncedRootGroup = PBXFileSystemSynchronizedRootGroup(
sourceTree: .group,
path: relativePath.string,
name: targetSource.name,
explicitFileTypes: [:],
exceptions: [],
explicitFolders: resolvedExplicitFolders
)
addObject(syncedRootGroup)
let syncedRootGroup: PBXFileSystemSynchronizedRootGroup
if let existingGroup = syncedGroupsByPath[relativePath.string] {
syncedRootGroup = existingGroup
let newExplicitFolders = Set(syncedRootGroup.explicitFolders ?? [])
.union(resolvedExplicitFolders)
.sorted()
syncedRootGroup.explicitFolders = newExplicitFolders
} else {
syncedRootGroup = PBXFileSystemSynchronizedRootGroup(
sourceTree: .group,
path: relativePath.string,
name: targetSource.name,
explicitFileTypes: [:],
exceptions: [],
explicitFolders: resolvedExplicitFolders
)
addObject(syncedRootGroup)
syncedGroupsByPath[relativePath.string] = syncedRootGroup
}
sourceReference = syncedRootGroup
if !(createIntermediateGroups || hasCustomParent) || path.parent() == project.basePath {
@@ -290,6 +290,207 @@ class SourceGeneratorTests: XCTestCase {
try expect(hasResourcesPhase) == true
}
$0.it("deduplicates synced folders across targets") {
let directories = """
Sources:
- a.swift
"""
try createDirectories(directories)
let source = TargetSource(path: "Sources", type: .syncedFolder)
let target1 = Target(name: "Target1", type: .application, platform: .iOS, sources: [source])
let target2 = Target(name: "Target2", type: .application, platform: .iOS, sources: [source])
let project = Project(basePath: directoryPath, name: "Test", targets: [target1, target2])
let pbxProj = try project.generatePbxProj()
let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
try expect(syncedFolders.count) == 1
}
$0.it("supports includes for synced folders") {
let directories = """
Sources:
- included.swift
- excluded.swift
"""
try createDirectories(directories)
let source = TargetSource(path: "Sources", includes: ["included.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("excluded.swift")) == true
try expect(exceptions.contains("included.swift")) == false
}
$0.it("merges explicitFolders for synced folders across targets") {
let directories = """
Sources:
- a.swift
- FolderA:
- b.swift
- FolderB:
- c.swift
"""
try createDirectories(directories)
let source1 = TargetSource(path: "Sources", explicitFolders: ["FolderA"], type: .syncedFolder)
let source2 = TargetSource(path: "Sources", explicitFolders: ["FolderB"], type: .syncedFolder)
let target1 = Target(name: "Target1", type: .application, platform: .iOS, sources: [source1])
let target2 = Target(name: "Target2", type: .application, platform: .iOS, sources: [source2])
let project = Project(basePath: directoryPath, name: "Test", targets: [target1, target2])
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?.sorted()) == ["FolderA", "FolderB"]
}
$0.it("supports different includes for the same synced folder across targets") {
let directories = """
Sources:
- target1.swift
- target2.swift
- common.swift
"""
try createDirectories(directories)
let source1 = TargetSource(path: "Sources", includes: ["target1.swift", "common.swift"], type: .syncedFolder)
let source2 = TargetSource(path: "Sources", includes: ["target2.swift", "common.swift"], type: .syncedFolder)
let target1 = Target(name: "Target1", type: .application, platform: .iOS, sources: [source1])
let target2 = Target(name: "Target2", type: .application, platform: .iOS, sources: [source2])
let project = Project(basePath: directoryPath, name: "Test", targets: [target1, target2])
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 }
try expect(exceptionSets?.count) == 2
let t1Exceptions = try unwrap(exceptionSets?.first { $0.target?.name == "Target1" }?.membershipExceptions)
try expect(t1Exceptions.contains("target2.swift")) == true
try expect(t1Exceptions.contains("target1.swift")) == false
try expect(t1Exceptions.contains("common.swift")) == false
let t2Exceptions = try unwrap(exceptionSets?.first { $0.target?.name == "Target2" }?.membershipExceptions)
try expect(t2Exceptions.contains("target1.swift")) == true
try expect(t2Exceptions.contains("target2.swift")) == false
try expect(t2Exceptions.contains("common.swift")) == false
}
$0.it("correctly identifies exceptions for nested directories in includes") {
let directories = """
Sources:
- a.swift
- Nested:
- b.swift
- c.swift
"""
try createDirectories(directories)
let source = TargetSource(path: "Sources", includes: ["Nested/b.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 exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet)
let exceptions = try unwrap(exceptionSet.membershipExceptions)
try expect(exceptions.contains("a.swift")) == true
try expect(exceptions.contains("Nested/c.swift")) == true
try expect(exceptions.contains("Nested/b.swift")) == false
}
$0.it("excludes entire subdirectory as single exception when no files in it are included") {
let directories = """
Sources:
- a.swift
- ExcludedDir:
- x.swift
- y.swift
"""
try createDirectories(directories)
let source = TargetSource(path: "Sources", includes: ["a.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 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
try expect(exceptions.contains("a.swift")) == false
}
$0.it("respects excludes when includes are also specified") {
let directories = """
Sources:
- a.swift
- b.swift
- c.swift
"""
try createDirectories(directories)
// includes a.swift and b.swift, but b.swift is also excluded only a.swift is effectively included
let source = TargetSource(path: "Sources", excludes: ["b.swift"], includes: ["a.swift", "b.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 exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet)
let exceptions = try unwrap(exceptionSet.membershipExceptions)
try expect(exceptions.contains("a.swift")) == false
try expect(exceptions.contains("b.swift")) == true
try expect(exceptions.contains("c.swift")) == true
}
$0.it("deduplicates synced folders and both targets reference the same group object") {
let directories = """
Sources:
- a.swift
"""
try createDirectories(directories)
let source = TargetSource(path: "Sources", type: .syncedFolder)
let target1 = Target(name: "App", type: .application, platform: .iOS, sources: [source])
let target2 = Target(name: "Tests", type: .unitTestBundle, platform: .iOS, sources: [source])
let project = Project(basePath: directoryPath, name: "Test", targets: [target1, target2])
let pbxProj = try project.generatePbxProj()
let nativeTargets = pbxProj.nativeTargets
let appTarget = try unwrap(nativeTargets.first { $0.name == "App" })
let testsTarget = try unwrap(nativeTargets.first { $0.name == "Tests" })
let appGroup = try unwrap(appTarget.fileSystemSynchronizedGroups?.first)
let testsGroup = try unwrap(testsTarget.fileSystemSynchronizedGroups?.first)
try expect(appGroup === testsGroup) == true
}
$0.it("supports frameworks in sources") {
let directories = """
Sources: