From 5ed07abb3f32bbac7701a84b017210398c2dccfb Mon Sep 17 00:00:00 2001 From: Bruno Coelho <4brunu@gmail.com> Date: Thu, 16 Apr 2026 11:24:44 +0100 Subject: [PATCH 1/2] Fix: Exclude default ignored files and xcconfigs from synced folder membership --- Sources/XcodeGenKit/PBXProjGenerator.swift | 2 +- Sources/XcodeGenKit/SourceGenerator.swift | 22 ++-- .../Project.xcodeproj/project.pbxproj | 3 + .../SourceGeneratorTests.swift | 105 +++++++++++++++++- .../SyncedFolderReproductionTests.swift | 82 ++++++++++++++ 5 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 Tests/XcodeGenKitTests/SyncedFolderReproductionTests.swift diff --git a/Sources/XcodeGenKit/PBXProjGenerator.swift b/Sources/XcodeGenKit/PBXProjGenerator.swift index 1f20e850..001fef22 100644 --- a/Sources/XcodeGenKit/PBXProjGenerator.swift +++ b/Sources/XcodeGenKit/PBXProjGenerator.swift @@ -1489,7 +1489,7 @@ public class PBXProjGenerator { }) else { return } var exceptions: Set = Set( - sourceGenerator.syncedFolderExceptions(for: targetSource, at: syncedPath) + sourceGenerator.syncedFolderExceptions(for: targetSource, at: syncedPath, targetType: target.type) .compactMap { try? $0.relativePath(from: syncedPath).string } ) diff --git a/Sources/XcodeGenKit/SourceGenerator.swift b/Sources/XcodeGenKit/SourceGenerator.swift index 59b28588..901a955d 100644 --- a/Sources/XcodeGenKit/SourceGenerator.swift +++ b/Sources/XcodeGenKit/SourceGenerator.swift @@ -404,13 +404,9 @@ class SourceGenerator { } /// 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 { + func syncedFolderExceptions(for targetSource: TargetSource, at syncedPath: Path, targetType: PBXProductType) -> Set { let excludePaths = expandedExcludes(for: targetSource) - if targetSource.includes.isEmpty { - return excludePaths - } - - let includePaths = SortedArray(getSourceMatches(targetSource: targetSource, patterns: targetSource.includes)) + let includePaths = targetSource.includes.isEmpty ? nil : SortedArray(getSourceMatches(targetSource: targetSource, patterns: targetSource.includes)) var exceptions: Set = [] func findExceptions(in path: Path) { @@ -420,6 +416,11 @@ class SourceGenerator { if isIncludedPath(child, excludePaths: excludePaths, includePaths: includePaths) { if child.isDirectory && !Xcode.isDirectoryFileWrapper(path: child) { findExceptions(in: child) + } else { + let buildPhase = getDefaultBuildPhase(for: child, targetType: targetType) + if buildPhase == nil || buildPhase == BuildPhaseSpec.none { + exceptions.insert(child) + } } } else if child.isDirectory && !Xcode.isDirectoryFileWrapper(path: child) { findExceptions(in: child) @@ -469,10 +470,15 @@ class SourceGenerator { } } + /// Checks whether the path is in any default excludes + func isDefaultExcluded(_ path: Path) -> Bool { + return defaultExcludedFiles.contains(where: { path.lastComponent == $0 }) + || (path.extension.map(defaultExcludedExtensions.contains) ?? false) + } + /// 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 }) - && !(path.extension.map(defaultExcludedExtensions.contains) ?? false) + return !isDefaultExcluded(path) && !excludePaths.contains(path) // If includes is empty, it's included. If it's not empty, the path either needs to match exactly, or it needs to be a direct parent of an included path. && (includePaths.flatMap { _isIncludedPathSorted(path, sortedPaths: $0) } ?? true) diff --git a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj index 8a2c46fc..04ca7f9b 100644 --- a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj +++ b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj @@ -837,7 +837,10 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( ExcludedFile.swift, + FeatureATests/__Snapshots__/.gitkeep, + FeatureBTests/__Snapshots__/.gitkeep, Info.plist, + Resources/.gitkeep, ); target = 0867B0DACEF28C11442DE8F7 /* App_iOS */; }; diff --git a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift index b8450c5f..a951c1b2 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -36,7 +36,7 @@ class SourceGeneratorTests: XCTestCase { } } - let files = getFiles(yaml, path: directoryPath).filter { $0.extension != nil } + let files = getFiles(yaml, path: directoryPath).filter { $0.extension != nil || $0.lastComponent.hasPrefix(".") } for file in files { try file.parent().mkpath() try file.write("") @@ -203,6 +203,33 @@ class SourceGeneratorTests: XCTestCase { try expect(exceptions.contains("a.swift")) == false } + $0.it("excludes .DS_Store and .xcconfig from synced folder membership") { + let directories = """ + Sources: + - a.swift + - .DS_Store + - config.xcconfig + - Subfolder: + - nested.xcconfig + """ + 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 syncedFolder = try unwrap(pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }.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("config.xcconfig")) == true + try expect(exceptions.contains("Subfolder/nested.xcconfig")) == true + try expect(exceptions.contains(".DS_Store")) == true + } + $0.it("adds membership exceptions for nested synced folder with intermediate groups") { let directories = """ Sources: @@ -308,6 +335,82 @@ class SourceGeneratorTests: XCTestCase { try expect(syncedFolders.count) == 1 } + $0.it("excludes xcconfig files from synced folder membership") { + let directories = """ + Sources: + - a.swift + - Config.xcconfig + - Icon.icon + """ + try createDirectories(directories) + _ = try createFile(at: "Sources/README", content: "") + + 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) + + let exceptionSets = syncedFolder.exceptions?.compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet } + let exceptionSet = try unwrap(exceptionSets?.first) + let exceptions = try unwrap(exceptionSet.membershipExceptions) + + try expect(exceptions.contains("Config.xcconfig")) == true + try expect(exceptions.contains("README")) == true + try expect(exceptions.contains("a.swift")) == false + try expect(exceptions.contains("Icon.icon")) == false + } + + $0.it("excludes nested xcconfig files from synced folder membership") { + let directories = """ + Sources: + Nested: + - a.swift + - Config.xcconfig + """ + 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) + + let exceptionSets = syncedFolder.exceptions?.compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet } + let exceptionSet = try unwrap(exceptionSets?.first) + let exceptions = try unwrap(exceptionSet.membershipExceptions) + + try expect(exceptions.contains("Nested/Config.xcconfig")) == true + try expect(exceptions.contains("Nested/a.swift")) == false + } + + $0.it("includes default excluded files in synced folder membership exceptions") { + let directories = """ + Sources: + - a.swift + - .DS_Store + - a.swift.orig + """ + 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) + + let exceptionSets = try unwrap(syncedFolder.exceptions?.compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet }) + let exceptionSet = try unwrap(exceptionSets.first) + let exceptions = try unwrap(exceptionSet.membershipExceptions) + try expect(exceptions.contains(".DS_Store")) == true + try expect(exceptions.contains("a.swift.orig")) == true + } $0.it("supports includes for synced folders") { let directories = """ Sources: diff --git a/Tests/XcodeGenKitTests/SyncedFolderReproductionTests.swift b/Tests/XcodeGenKitTests/SyncedFolderReproductionTests.swift new file mode 100644 index 00000000..d5ef677d --- /dev/null +++ b/Tests/XcodeGenKitTests/SyncedFolderReproductionTests.swift @@ -0,0 +1,82 @@ +import PathKit +import ProjectSpec +import Spectre +@testable import XcodeGenKit +import XcodeProj +import XCTest +import Yams +import TestSupport + +class SyncedFolderReproductionTests: XCTestCase { + + func testSyncedFolderReproduction() throws { + describe { + let directoryPath = Path("TestDirectory") + + func createDirectories(_ directories: String) throws { + let yaml = try Yams.load(yaml: directories)! + + func getFiles(_ file: Any, path: Path) -> [Path] { + if let array = file as? [Any] { + return array.flatMap { getFiles($0, path: path) } + } else if let string = file as? String { + return [path + string] + } else if let dictionary = file as? [String: Any] { + var array: [Path] = [] + for (key, value) in dictionary { + array += getFiles(value, path: path + key) + } + return array + } else { + return [] + } + } + + let files = getFiles(yaml, path: directoryPath) + for file in files { + try file.parent().mkpath() + try file.write("") + } + } + + func removeDirectories() { + try? directoryPath.delete() + } + + $0.before { + removeDirectories() + } + + $0.after { + removeDirectories() + } + + $0.it("excludes .DS_Store and .xcconfig from synced folder membership") { + let directories = """ + Sources: + - a.swift + - .DS_Store + - config.xcconfig + - Subfolder: + - nested.xcconfig + """ + 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 syncedFolder = try unwrap(pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }.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("config.xcconfig")) == true + try expect(exceptions.contains("Subfolder/nested.xcconfig")) == true + try expect(exceptions.contains(".DS_Store")) == true + } + } + } +} From c49720d57b995edf08c8ca87f1fa9bd58bb6b402 Mon Sep 17 00:00:00 2001 From: Bruno Coelho <4brunu@gmail.com> Date: Thu, 16 Apr 2026 15:52:15 +0100 Subject: [PATCH 2/2] Fix: Exclude default ignored files and xcconfigs from synced folder membership --- Sources/XcodeGenKit/SourceGenerator.swift | 2 +- .../Project.xcodeproj/project.pbxproj | 3 - .../SourceGeneratorTests.swift | 2 +- .../SyncedFolderReproductionTests.swift | 82 ------------------- 4 files changed, 2 insertions(+), 87 deletions(-) delete mode 100644 Tests/XcodeGenKitTests/SyncedFolderReproductionTests.swift diff --git a/Sources/XcodeGenKit/SourceGenerator.swift b/Sources/XcodeGenKit/SourceGenerator.swift index 901a955d..5fad6aeb 100644 --- a/Sources/XcodeGenKit/SourceGenerator.swift +++ b/Sources/XcodeGenKit/SourceGenerator.swift @@ -418,7 +418,7 @@ class SourceGenerator { findExceptions(in: child) } else { let buildPhase = getDefaultBuildPhase(for: child, targetType: targetType) - if buildPhase == nil || buildPhase == BuildPhaseSpec.none { + if buildPhase == BuildPhaseSpec.none { exceptions.insert(child) } } diff --git a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj index 04ca7f9b..8a2c46fc 100644 --- a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj +++ b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj @@ -837,10 +837,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( ExcludedFile.swift, - FeatureATests/__Snapshots__/.gitkeep, - FeatureBTests/__Snapshots__/.gitkeep, Info.plist, - Resources/.gitkeep, ); target = 0867B0DACEF28C11442DE8F7 /* App_iOS */; }; diff --git a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift index a951c1b2..82bda703 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -358,7 +358,7 @@ class SourceGeneratorTests: XCTestCase { let exceptions = try unwrap(exceptionSet.membershipExceptions) try expect(exceptions.contains("Config.xcconfig")) == true - try expect(exceptions.contains("README")) == true + try expect(exceptions.contains("README")) == false try expect(exceptions.contains("a.swift")) == false try expect(exceptions.contains("Icon.icon")) == false } diff --git a/Tests/XcodeGenKitTests/SyncedFolderReproductionTests.swift b/Tests/XcodeGenKitTests/SyncedFolderReproductionTests.swift deleted file mode 100644 index d5ef677d..00000000 --- a/Tests/XcodeGenKitTests/SyncedFolderReproductionTests.swift +++ /dev/null @@ -1,82 +0,0 @@ -import PathKit -import ProjectSpec -import Spectre -@testable import XcodeGenKit -import XcodeProj -import XCTest -import Yams -import TestSupport - -class SyncedFolderReproductionTests: XCTestCase { - - func testSyncedFolderReproduction() throws { - describe { - let directoryPath = Path("TestDirectory") - - func createDirectories(_ directories: String) throws { - let yaml = try Yams.load(yaml: directories)! - - func getFiles(_ file: Any, path: Path) -> [Path] { - if let array = file as? [Any] { - return array.flatMap { getFiles($0, path: path) } - } else if let string = file as? String { - return [path + string] - } else if let dictionary = file as? [String: Any] { - var array: [Path] = [] - for (key, value) in dictionary { - array += getFiles(value, path: path + key) - } - return array - } else { - return [] - } - } - - let files = getFiles(yaml, path: directoryPath) - for file in files { - try file.parent().mkpath() - try file.write("") - } - } - - func removeDirectories() { - try? directoryPath.delete() - } - - $0.before { - removeDirectories() - } - - $0.after { - removeDirectories() - } - - $0.it("excludes .DS_Store and .xcconfig from synced folder membership") { - let directories = """ - Sources: - - a.swift - - .DS_Store - - config.xcconfig - - Subfolder: - - nested.xcconfig - """ - 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 syncedFolder = try unwrap(pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }.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("config.xcconfig")) == true - try expect(exceptions.contains("Subfolder/nested.xcconfig")) == true - try expect(exceptions.contains(".DS_Store")) == true - } - } - } -}