Skip to content
This repository was archived by the owner on Feb 26, 2026. It is now read-only.

Commit 57aa7d6

Browse files
authored
fix: mapping build phases with exceptions (#122)
* fix: mapping build phases with exceptions * fly-by fixes * Update XcodeProj * Update XcodeProj
1 parent 8fbb1c6 commit 57aa7d6

8 files changed

Lines changed: 162 additions & 40 deletions

File tree

Package.resolved

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ let package = Package(
8080
dependencies: [
8181
.package(url: "https://github.com/Flight-School/AnyCodable", .upToNextMajor(from: "0.6.7")),
8282
.package(url: "https://github.com/tuist/Path.git", .upToNextMajor(from: "0.3.8")),
83-
.package(url: "https://github.com/tuist/XcodeProj", from: "8.26.7"),
83+
.package(url: "https://github.com/tuist/XcodeProj", .upToNextMajor(from: "8.27.0")),
8484
.package(url: "https://github.com/tuist/Command.git", from: "0.12.2"),
8585
.package(url: "https://github.com/tuist/FileSystem.git", .upToNextMajor(from: "0.7.7")),
8686
.package(url: "https://github.com/apple/swift-service-context", .upToNextMajor(from: "1.2.0")),

Sources/XcodeGraphMapper/Mappers/Phases/PBXCopyFilesBuildPhaseMapper.swift

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,29 @@ protocol PBXCopyFilesBuildPhaseMapping {
1111
/// - xcodeProj: The `XcodeProj` containing project configuration and file references.
1212
/// - Returns: An array of mapped `CopyFilesAction`s.
1313
/// - Throws: If any file paths are invalid or cannot be resolved.
14-
func map(_ copyFilesPhases: [PBXCopyFilesBuildPhase], xcodeProj: XcodeProj) throws -> [CopyFilesAction]
14+
func map(
15+
_ copyFilesPhases: [PBXCopyFilesBuildPhase],
16+
fileSystemSynchronizedGroups: [PBXFileSystemSynchronizedRootGroup],
17+
xcodeProj: XcodeProj
18+
) throws -> [CopyFilesAction]
1519
}
1620

1721
/// A mapper that converts `PBXCopyFilesBuildPhase` objects into `CopyFilesAction` domain models.
1822
struct PBXCopyFilesBuildPhaseMapper: PBXCopyFilesBuildPhaseMapping {
1923
/// Maps the provided copy files phases to sorted `CopyFilesAction` models.
20-
func map(_ copyFilesPhases: [PBXCopyFilesBuildPhase], xcodeProj: XcodeProj) throws -> [CopyFilesAction] {
24+
func map(
25+
_ copyFilesPhases: [PBXCopyFilesBuildPhase],
26+
fileSystemSynchronizedGroups: [PBXFileSystemSynchronizedRootGroup],
27+
xcodeProj: XcodeProj
28+
) throws -> [CopyFilesAction] {
2129
try copyFilesPhases
22-
.compactMap { try mapCopyFilesPhase($0, xcodeProj: xcodeProj) }
30+
.compactMap {
31+
try mapCopyFilesPhase(
32+
$0,
33+
fileSystemSynchronizedGroups: fileSystemSynchronizedGroups,
34+
xcodeProj: xcodeProj
35+
)
36+
}
2337
.sorted { $0.name < $1.name }
2438
}
2539

@@ -33,6 +47,7 @@ struct PBXCopyFilesBuildPhaseMapper: PBXCopyFilesBuildPhaseMapping {
3347
/// - Throws: If file paths are invalid or unresolved.
3448
private func mapCopyFilesPhase(
3549
_ phase: PBXCopyFilesBuildPhase,
50+
fileSystemSynchronizedGroups: [PBXFileSystemSynchronizedRootGroup],
3651
xcodeProj: XcodeProj
3752
) throws -> CopyFilesAction? {
3853
let files = try (phase.files ?? [])
@@ -50,15 +65,47 @@ struct PBXCopyFilesBuildPhaseMapper: PBXCopyFilesBuildPhaseMapping {
5065
return .file(path: absolutePath, condition: nil, codeSignOnCopy: codeSignOnCopy)
5166
}
5267
.sorted { $0.path < $1.path }
68+
let groupsFiles = fileSystemSynchronizedGroupsFiles(
69+
phase,
70+
fileSystemSynchronizedGroups: fileSystemSynchronizedGroups,
71+
xcodeProj: xcodeProj
72+
)
5373

5474
return CopyFilesAction(
5575
name: phase.name ?? BuildPhaseConstants.copyFilesDefault,
5676
destination: mapDstSubfolderSpec(phase.dstSubfolderSpec),
5777
subpath: (phase.dstPath?.isEmpty == true) ? nil : phase.dstPath,
58-
files: files
78+
files: files + groupsFiles
5979
)
6080
}
6181

82+
private func fileSystemSynchronizedGroupsFiles(
83+
_ phase: PBXCopyFilesBuildPhase,
84+
fileSystemSynchronizedGroups: [PBXFileSystemSynchronizedRootGroup],
85+
xcodeProj: XcodeProj
86+
) -> [CopyFileElement] {
87+
var files: [CopyFileElement] = []
88+
for fileSystemSynchronizedGroup in fileSystemSynchronizedGroups {
89+
if let path = fileSystemSynchronizedGroup.path {
90+
let buildPhaseExceptions = fileSystemSynchronizedGroup.exceptions?
91+
.compactMap { $0 as? PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet }
92+
.filter { $0.buildPhase == phase } ?? []
93+
let groupFiles = buildPhaseExceptions.compactMap {
94+
$0.membershipExceptions?.map {
95+
return CopyFileElement.file(
96+
path: xcodeProj.srcPath.appending(component: path).appending(RelativePath($0)),
97+
condition: nil,
98+
codeSignOnCopy: true
99+
)
100+
}
101+
}
102+
.flatMap { $0 }
103+
files.append(contentsOf: groupFiles)
104+
}
105+
}
106+
return files
107+
}
108+
62109
/// Maps a `PBXCopyFilesBuildPhase.SubFolder` to a `CopyFilesAction.Destination`.
63110
private func mapDstSubfolderSpec(
64111
_ subfolderSpec: PBXCopyFilesBuildPhase.SubFolder?

Sources/XcodeGraphMapper/Mappers/Project/PBXProjectMapper.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ struct PBXProjectMapper: PBXProjectMapping {
175175
let pathString = try file.fullPath(sourceRoot: xcodeProj.srcPathString)
176176
{
177177
let path = try AbsolutePath(validating: pathString)
178-
if try await fileSystem.exists(path, isDirectory: true),
178+
if (try? await fileSystem.exists(path, isDirectory: true)) ?? false,
179179
try await fileSystem.exists(path.appending(component: "Package.swift"))
180180
{
181181
packages.insert(path)

Sources/XcodeGraphMapper/Mappers/Targets/PBXTarget+GraphMapping.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ extension PBXTarget {
88
func bundleIdentifier() throws -> String {
99
if let bundleId = debugBuildSettings.string(for: .productBundleIdentifier) {
1010
return bundleId
11+
} else {
12+
return "Unknown"
1113
}
12-
throw PBXTargetMappingError.missingBundleIdentifier(targetName: name)
1314
}
1415

1516
/// Returns an array of all `PBXCopyFilesBuildPhase` instances for this target.

Sources/XcodeGraphMapper/Mappers/Targets/PBXTargetMapper.swift

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ enum PBXTargetMappingError: LocalizedError, Equatable {
1010
case noProjectsFound(path: String)
1111
case missingFilesGroup(targetName: String)
1212
case invalidPlist(path: String)
13-
case missingBundleIdentifier(targetName: String)
1413

1514
var errorDescription: String? {
1615
switch self {
@@ -20,8 +19,6 @@ enum PBXTargetMappingError: LocalizedError, Equatable {
2019
return "The files group is missing for the target '\(targetName)'."
2120
case let .invalidPlist(path):
2221
return "Failed to read a valid plist dictionary from file at: \(path)."
23-
case let .missingBundleIdentifier(targetName):
24-
return "The bundle identifier is missing for the target '\(targetName)'."
2522
}
2623
}
2724
}
@@ -150,7 +147,11 @@ struct PBXTargetMapper: PBXTargetMapping {
150147
let rawScriptBuildPhases = scriptsMapper.mapRawScriptBuildPhases(runScriptPhases)
151148

152149
let copyFilesPhases = pbxTarget.copyFilesBuildPhases()
153-
let copyFiles = try copyFilesMapper.map(copyFilesPhases, xcodeProj: xcodeProj)
150+
let copyFiles = try copyFilesMapper.map(
151+
copyFilesPhases,
152+
fileSystemSynchronizedGroups: pbxTarget.fileSystemSynchronizedGroups ?? [],
153+
xcodeProj: xcodeProj
154+
)
154155

155156
// Core Data models
156157
let resourceFiles = try pbxTarget.resourcesBuildPhase()?.files ?? []
@@ -390,8 +391,9 @@ struct PBXTargetMapper: PBXTargetMapping {
390391
for fileSystemSynchronizedGroup in fileSystemSynchronizedGroups {
391392
if let path = fileSystemSynchronizedGroup.path {
392393
let membershipExceptions = membershipExceptions(for: fileSystemSynchronizedGroup)
393-
let additionalCompilerFlagsByRelativePath = fileSystemSynchronizedGroup.exceptions?
394+
let additionalCompilerFlagsByRelativePath: [String: String]? = fileSystemSynchronizedGroup.exceptions?
394395
.reduce(into: [:]) { acc, element in
396+
guard let element = element as? PBXFileSystemSynchronizedBuildFileExceptionSet else { return }
395397
acc.merge(element.additionalCompilerFlagsByRelativePath ?? [:], uniquingKeysWith: { $1 })
396398
}
397399
let directory = xcodeProj.srcPath.appending(component: path)
@@ -429,10 +431,7 @@ struct PBXTargetMapper: PBXTargetMapping {
429431
for fileSystemSynchronizedGroup in fileSystemSynchronizedGroups {
430432
guard let path = fileSystemSynchronizedGroup.path else { continue }
431433
let directory = xcodeProj.srcPath.appending(component: path)
432-
let membershipExceptions = Set(
433-
fileSystemSynchronizedGroup.exceptions
434-
.map { $0.compactMap(\.membershipExceptions).flatMap { $0 } } ?? []
435-
)
434+
let membershipExceptions = membershipExceptions(for: fileSystemSynchronizedGroup)
436435

437436
let groupResources = try await globFiles(
438437
directory: directory,
@@ -463,9 +462,10 @@ struct PBXTargetMapper: PBXTargetMapping {
463462
guard let path = fileSystemSynchronizedGroup.path else { continue }
464463
let directory = xcodeProj.srcPath.appending(component: path)
465464
let membershipExceptions = membershipExceptions(for: fileSystemSynchronizedGroup)
466-
let attributesByRelativePath = fileSystemSynchronizedGroup.exceptions?.reduce([:]) { acc, element in
467-
acc.merging(element.attributesByRelativePath ?? [:], uniquingKeysWith: { $1 })
468-
}
465+
let attributesByRelativePath = fileSystemSynchronizedGroup.exceptions?
466+
.compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet }.reduce([:]) { acc, element in
467+
acc.merging(element.attributesByRelativePath ?? [:], uniquingKeysWith: { $1 })
468+
}
469469

470470
let groupFrameworks: [TargetDependency] = try await globFiles(
471471
directory: directory,
@@ -500,7 +500,9 @@ struct PBXTargetMapper: PBXTargetMapping {
500500
for fileSystemSynchronizedGroup in fileSystemSynchronizedGroups {
501501
guard let path = fileSystemSynchronizedGroup.path else { continue }
502502
let directory = xcodeProj.srcPath.appending(component: path)
503-
for synchronizedBuildFileSystemExceptionSet in fileSystemSynchronizedGroup.exceptions ?? [] {
503+
for synchronizedBuildFileSystemExceptionSet in fileSystemSynchronizedGroup.exceptions?
504+
.compactMap({ $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet }) ?? []
505+
{
504506
for publicHeader in synchronizedBuildFileSystemExceptionSet.publicHeaders ?? [] {
505507
publicHeaders.append(directory.appending(component: publicHeader))
506508
}
@@ -539,6 +541,7 @@ struct PBXTargetMapper: PBXTargetMapping {
539541
private func membershipExceptions(for fileSystemSynchronizedGroup: PBXFileSystemSynchronizedRootGroup) -> Set<String> {
540542
Set(
541543
fileSystemSynchronizedGroup.exceptions
544+
.map { $0.compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet } }
542545
.map { $0.compactMap(\.membershipExceptions).flatMap { $0 } } ?? []
543546
)
544547
}

Tests/XcodeGraphMapperTests/MapperTests/Phases/PBXCopyFilesBuildPhaseMapperTests.swift

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,11 @@ struct PBXCopyFilesBuildPhaseMapperTests {
4343
let mapper = PBXCopyFilesBuildPhaseMapper()
4444

4545
// When
46-
let copyActions = try mapper.map([copyFilesPhase], xcodeProj: xcodeProj)
46+
let copyActions = try mapper.map(
47+
[copyFilesPhase],
48+
fileSystemSynchronizedGroups: [],
49+
xcodeProj: xcodeProj
50+
)
4751

4852
// Then
4953
#expect(copyActions.count == 1)
@@ -58,4 +62,70 @@ struct PBXCopyFilesBuildPhaseMapperTests {
5862
#expect(fileAction.codeSignOnCopy == true)
5963
#expect(fileAction.path.basename == "MyLibrary.dylib")
6064
}
65+
66+
@Test("Maps copy files actions with a synchronized group")
67+
func testMapCopyFilesWithSynchronizedGroup() async throws {
68+
// Given
69+
let xcodeProj = try await XcodeProj.test(
70+
path: "/tmp/TestProject/Project.xcodeproj"
71+
)
72+
let pbxProj = xcodeProj.pbxproj
73+
74+
let copyFilesPhase = PBXCopyFilesBuildPhase(
75+
dstPath: "XPC Services",
76+
dstSubfolderSpec: .productsDirectory,
77+
name: "Copy files",
78+
files: []
79+
)
80+
.add(to: pbxProj)
81+
82+
try PBXNativeTarget.test(
83+
name: "App",
84+
buildPhases: [copyFilesPhase],
85+
productType: .application
86+
)
87+
.add(to: pbxProj)
88+
.add(to: pbxProj.rootObject)
89+
90+
let mapper = PBXCopyFilesBuildPhaseMapper()
91+
let exceptionSet = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet(
92+
buildPhase: copyFilesPhase,
93+
membershipExceptions: [
94+
"XCPService.xpc",
95+
],
96+
attributesByRelativePath: nil
97+
)
98+
let rootGroup = PBXFileSystemSynchronizedRootGroup(
99+
path: "SynchronizedRootGroup",
100+
exceptions: [
101+
exceptionSet,
102+
]
103+
)
104+
105+
// When
106+
let copyActions = try mapper.map(
107+
[copyFilesPhase],
108+
fileSystemSynchronizedGroups: [
109+
rootGroup,
110+
],
111+
xcodeProj: xcodeProj
112+
)
113+
114+
// Then
115+
#expect(copyActions.count == 1)
116+
117+
let action = try #require(copyActions.first)
118+
#expect(action.name == "Copy files")
119+
#expect(action.destination == .productsDirectory)
120+
#expect(action.subpath == "XPC Services")
121+
#expect(
122+
action.files == [
123+
.file(
124+
path: "/tmp/TestProject/SynchronizedRootGroup/XCPService.xpc",
125+
condition: nil,
126+
codeSignOnCopy: true
127+
),
128+
]
129+
)
130+
}
61131
}

Tests/XcodeGraphMapperTests/MapperTests/Target/PBXTargetMapperTests.swift

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ struct PBXTargetMapperTests: Sendable {
4040
#expect(mapped.bundleId == "com.example.app")
4141
}
4242

43-
@Test("Throws an error if the target is missing a bundle identifier")
43+
@Test("Defaults to unknown if the target is missing a bundle identifier")
4444
func testMapTargetWithMissingBundleId() async throws {
4545
// Given
4646
let xcodeProj = try await XcodeProj.test()
@@ -53,15 +53,16 @@ struct PBXTargetMapperTests: Sendable {
5353
)
5454
let mapper = PBXTargetMapper()
5555

56-
// When / Then
57-
await #expect(throws: PBXTargetMappingError.missingBundleIdentifier(targetName: "App")) {
58-
_ = try await mapper.map(
59-
pbxTarget: target,
60-
xcodeProj: xcodeProj,
61-
projectNativeTargets: [:],
62-
packages: []
63-
)
64-
}
56+
// When
57+
let mapped = try await mapper.map(
58+
pbxTarget: target,
59+
xcodeProj: xcodeProj,
60+
projectNativeTargets: [:],
61+
packages: []
62+
)
63+
64+
// Then
65+
#expect(mapped.bundleId == "Unknown")
6566
}
6667

6768
@Test("Maps a target with environment variables")

0 commit comments

Comments
 (0)