Skip to content

Commit acd366f

Browse files
authored
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
1 parent 3114831 commit acd366f

File tree

3 files changed

+250
-10
lines changed

3 files changed

+250
-10
lines changed

Sources/XcodeGenKit/PBXProjGenerator.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1489,7 +1489,7 @@ public class PBXProjGenerator {
14891489
}) else { return }
14901490

14911491
var exceptions: Set<String> = Set(
1492-
sourceGenerator.expandedExcludes(for: targetSource)
1492+
sourceGenerator.syncedFolderExceptions(for: targetSource, at: syncedPath)
14931493
.compactMap { try? $0.relativePath(from: syncedPath).string }
14941494
)
14951495

Sources/XcodeGenKit/SourceGenerator.swift

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class SourceGenerator {
1818
private var fileReferencesByPath: [String: PBXFileElement] = [:]
1919
private var groupsByPath: [Path: PBXGroup] = [:]
2020
private var variantGroupsByPath: [Path: PBXVariantGroup] = [:]
21+
private var syncedGroupsByPath: [String: PBXFileSystemSynchronizedRootGroup] = [:]
2122

2223
private let project: Project
2324
let pbxProj: PBXProj
@@ -377,6 +378,34 @@ class SourceGenerator {
377378
getSourceMatches(targetSource: targetSource, patterns: targetSource.excludes)
378379
}
379380

381+
/// Returns the expanded set of exception paths for a synced folder, including excludes and non-included files.
382+
func syncedFolderExceptions(for targetSource: TargetSource, at syncedPath: Path) -> Set<Path> {
383+
let excludePaths = expandedExcludes(for: targetSource)
384+
if targetSource.includes.isEmpty {
385+
return excludePaths
386+
}
387+
388+
let includePaths = SortedArray(getSourceMatches(targetSource: targetSource, patterns: targetSource.includes))
389+
var exceptions: Set<Path> = []
390+
391+
func findExceptions(in path: Path) {
392+
guard let children = try? path.children() else { return }
393+
394+
for child in children {
395+
if isIncludedPath(child, excludePaths: excludePaths, includePaths: includePaths) {
396+
if child.isDirectory && !Xcode.isDirectoryFileWrapper(path: child) {
397+
findExceptions(in: child)
398+
}
399+
} else {
400+
exceptions.insert(child)
401+
}
402+
}
403+
}
404+
405+
findExceptions(in: syncedPath)
406+
return exceptions
407+
}
408+
380409
/// Collects all the excluded paths within the targetSource
381410
private func getSourceMatches(targetSource: TargetSource, patterns: [String]) -> Set<Path> {
382411
let rootSourcePath = project.basePath + targetSource.path
@@ -711,15 +740,25 @@ class SourceGenerator {
711740
let relativePath = (try? path.relativePath(from: project.basePath)) ?? path
712741
let resolvedExplicitFolders = resolveExplicitFolders(targetSource: targetSource)
713742

714-
let syncedRootGroup = PBXFileSystemSynchronizedRootGroup(
715-
sourceTree: .group,
716-
path: relativePath.string,
717-
name: targetSource.name,
718-
explicitFileTypes: [:],
719-
exceptions: [],
720-
explicitFolders: resolvedExplicitFolders
721-
)
722-
addObject(syncedRootGroup)
743+
let syncedRootGroup: PBXFileSystemSynchronizedRootGroup
744+
if let existingGroup = syncedGroupsByPath[relativePath.string] {
745+
syncedRootGroup = existingGroup
746+
let newExplicitFolders = Set(syncedRootGroup.explicitFolders ?? [])
747+
.union(resolvedExplicitFolders)
748+
.sorted()
749+
syncedRootGroup.explicitFolders = newExplicitFolders
750+
} else {
751+
syncedRootGroup = PBXFileSystemSynchronizedRootGroup(
752+
sourceTree: .group,
753+
path: relativePath.string,
754+
name: targetSource.name,
755+
explicitFileTypes: [:],
756+
exceptions: [],
757+
explicitFolders: resolvedExplicitFolders
758+
)
759+
addObject(syncedRootGroup)
760+
syncedGroupsByPath[relativePath.string] = syncedRootGroup
761+
}
723762
sourceReference = syncedRootGroup
724763

725764
if !(createIntermediateGroups || hasCustomParent) || path.parent() == project.basePath {

Tests/XcodeGenKitTests/SourceGeneratorTests.swift

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,207 @@ class SourceGeneratorTests: XCTestCase {
290290
try expect(hasResourcesPhase) == true
291291
}
292292

293+
$0.it("deduplicates synced folders across targets") {
294+
let directories = """
295+
Sources:
296+
- a.swift
297+
"""
298+
try createDirectories(directories)
299+
300+
let source = TargetSource(path: "Sources", type: .syncedFolder)
301+
let target1 = Target(name: "Target1", type: .application, platform: .iOS, sources: [source])
302+
let target2 = Target(name: "Target2", type: .application, platform: .iOS, sources: [source])
303+
let project = Project(basePath: directoryPath, name: "Test", targets: [target1, target2])
304+
305+
let pbxProj = try project.generatePbxProj()
306+
let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
307+
308+
try expect(syncedFolders.count) == 1
309+
}
310+
311+
$0.it("supports includes for synced folders") {
312+
let directories = """
313+
Sources:
314+
- included.swift
315+
- excluded.swift
316+
"""
317+
try createDirectories(directories)
318+
319+
let source = TargetSource(path: "Sources", includes: ["included.swift"], type: .syncedFolder)
320+
let target = Target(name: "Test", type: .application, platform: .iOS, sources: [source])
321+
let project = Project(basePath: directoryPath, name: "Test", targets: [target])
322+
323+
let pbxProj = try project.generatePbxProj()
324+
let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
325+
let syncedFolder = try unwrap(syncedFolders.first)
326+
327+
let exceptionSets = syncedFolder.exceptions?.compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet }
328+
let exceptionSet = try unwrap(exceptionSets?.first)
329+
let exceptions = try unwrap(exceptionSet.membershipExceptions)
330+
331+
try expect(exceptions.contains("excluded.swift")) == true
332+
try expect(exceptions.contains("included.swift")) == false
333+
}
334+
335+
$0.it("merges explicitFolders for synced folders across targets") {
336+
let directories = """
337+
Sources:
338+
- a.swift
339+
- FolderA:
340+
- b.swift
341+
- FolderB:
342+
- c.swift
343+
"""
344+
try createDirectories(directories)
345+
346+
let source1 = TargetSource(path: "Sources", explicitFolders: ["FolderA"], type: .syncedFolder)
347+
let source2 = TargetSource(path: "Sources", explicitFolders: ["FolderB"], type: .syncedFolder)
348+
let target1 = Target(name: "Target1", type: .application, platform: .iOS, sources: [source1])
349+
let target2 = Target(name: "Target2", type: .application, platform: .iOS, sources: [source2])
350+
let project = Project(basePath: directoryPath, name: "Test", targets: [target1, target2])
351+
352+
let pbxProj = try project.generatePbxProj()
353+
let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
354+
let syncedFolder = try unwrap(syncedFolders.first)
355+
356+
try expect(syncedFolder.explicitFolders?.sorted()) == ["FolderA", "FolderB"]
357+
}
358+
359+
$0.it("supports different includes for the same synced folder across targets") {
360+
let directories = """
361+
Sources:
362+
- target1.swift
363+
- target2.swift
364+
- common.swift
365+
"""
366+
try createDirectories(directories)
367+
368+
let source1 = TargetSource(path: "Sources", includes: ["target1.swift", "common.swift"], type: .syncedFolder)
369+
let source2 = TargetSource(path: "Sources", includes: ["target2.swift", "common.swift"], type: .syncedFolder)
370+
let target1 = Target(name: "Target1", type: .application, platform: .iOS, sources: [source1])
371+
let target2 = Target(name: "Target2", type: .application, platform: .iOS, sources: [source2])
372+
let project = Project(basePath: directoryPath, name: "Test", targets: [target1, target2])
373+
374+
let pbxProj = try project.generatePbxProj()
375+
let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
376+
let syncedFolder = try unwrap(syncedFolders.first)
377+
378+
let exceptionSets = syncedFolder.exceptions?.compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet }
379+
try expect(exceptionSets?.count) == 2
380+
381+
let t1Exceptions = try unwrap(exceptionSets?.first { $0.target?.name == "Target1" }?.membershipExceptions)
382+
try expect(t1Exceptions.contains("target2.swift")) == true
383+
try expect(t1Exceptions.contains("target1.swift")) == false
384+
try expect(t1Exceptions.contains("common.swift")) == false
385+
386+
let t2Exceptions = try unwrap(exceptionSets?.first { $0.target?.name == "Target2" }?.membershipExceptions)
387+
try expect(t2Exceptions.contains("target1.swift")) == true
388+
try expect(t2Exceptions.contains("target2.swift")) == false
389+
try expect(t2Exceptions.contains("common.swift")) == false
390+
}
391+
392+
$0.it("correctly identifies exceptions for nested directories in includes") {
393+
let directories = """
394+
Sources:
395+
- a.swift
396+
- Nested:
397+
- b.swift
398+
- c.swift
399+
"""
400+
try createDirectories(directories)
401+
402+
let source = TargetSource(path: "Sources", includes: ["Nested/b.swift"], type: .syncedFolder)
403+
let target = Target(name: "Test", type: .application, platform: .iOS, sources: [source])
404+
let project = Project(basePath: directoryPath, name: "Test", targets: [target])
405+
406+
let pbxProj = try project.generatePbxProj()
407+
let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
408+
let syncedFolder = try unwrap(syncedFolders.first)
409+
410+
let exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet)
411+
let exceptions = try unwrap(exceptionSet.membershipExceptions)
412+
413+
try expect(exceptions.contains("a.swift")) == true
414+
try expect(exceptions.contains("Nested/c.swift")) == true
415+
try expect(exceptions.contains("Nested/b.swift")) == false
416+
}
417+
418+
$0.it("excludes entire subdirectory as single exception when no files in it are included") {
419+
let directories = """
420+
Sources:
421+
- a.swift
422+
- ExcludedDir:
423+
- x.swift
424+
- y.swift
425+
"""
426+
try createDirectories(directories)
427+
428+
let source = TargetSource(path: "Sources", includes: ["a.swift"], type: .syncedFolder)
429+
let target = Target(name: "Test", type: .application, platform: .iOS, sources: [source])
430+
let project = Project(basePath: directoryPath, name: "Test", targets: [target])
431+
432+
let pbxProj = try project.generatePbxProj()
433+
let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
434+
let syncedFolder = try unwrap(syncedFolders.first)
435+
436+
let exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet)
437+
let exceptions = try unwrap(exceptionSet.membershipExceptions)
438+
439+
// The whole directory should be a single exception entry, not each file within it
440+
try expect(exceptions.contains("ExcludedDir")) == true
441+
try expect(exceptions.contains("ExcludedDir/x.swift")) == false
442+
try expect(exceptions.contains("ExcludedDir/y.swift")) == false
443+
try expect(exceptions.contains("a.swift")) == false
444+
}
445+
446+
$0.it("respects excludes when includes are also specified") {
447+
let directories = """
448+
Sources:
449+
- a.swift
450+
- b.swift
451+
- c.swift
452+
"""
453+
try createDirectories(directories)
454+
455+
// includes a.swift and b.swift, but b.swift is also excluded → only a.swift is effectively included
456+
let source = TargetSource(path: "Sources", excludes: ["b.swift"], includes: ["a.swift", "b.swift"], type: .syncedFolder)
457+
let target = Target(name: "Test", type: .application, platform: .iOS, sources: [source])
458+
let project = Project(basePath: directoryPath, name: "Test", targets: [target])
459+
460+
let pbxProj = try project.generatePbxProj()
461+
let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
462+
let syncedFolder = try unwrap(syncedFolders.first)
463+
464+
let exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet)
465+
let exceptions = try unwrap(exceptionSet.membershipExceptions)
466+
467+
try expect(exceptions.contains("a.swift")) == false
468+
try expect(exceptions.contains("b.swift")) == true
469+
try expect(exceptions.contains("c.swift")) == true
470+
}
471+
472+
$0.it("deduplicates synced folders and both targets reference the same group object") {
473+
let directories = """
474+
Sources:
475+
- a.swift
476+
"""
477+
try createDirectories(directories)
478+
479+
let source = TargetSource(path: "Sources", type: .syncedFolder)
480+
let target1 = Target(name: "App", type: .application, platform: .iOS, sources: [source])
481+
let target2 = Target(name: "Tests", type: .unitTestBundle, platform: .iOS, sources: [source])
482+
let project = Project(basePath: directoryPath, name: "Test", targets: [target1, target2])
483+
484+
let pbxProj = try project.generatePbxProj()
485+
let nativeTargets = pbxProj.nativeTargets
486+
let appTarget = try unwrap(nativeTargets.first { $0.name == "App" })
487+
let testsTarget = try unwrap(nativeTargets.first { $0.name == "Tests" })
488+
489+
let appGroup = try unwrap(appTarget.fileSystemSynchronizedGroups?.first)
490+
let testsGroup = try unwrap(testsTarget.fileSystemSynchronizedGroups?.first)
491+
try expect(appGroup === testsGroup) == true
492+
}
493+
293494
$0.it("supports frameworks in sources") {
294495
let directories = """
295496
Sources:

0 commit comments

Comments
 (0)