Skip to content

Commit df251b2

Browse files
AlexNsbmrclaude
andauthored
Fix synced folder issues: configFiles duplicate, group insertion, and directory-level membershipExceptions (#1607)
* 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. * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * Use basePath instead of project.basePath for synced folder checks When XcodeGen is run with --project-directory, the computed basePath property accounts for the different output location. Using project.basePath would resolve paths incorrectly in that scenario. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3e6e5e3 commit df251b2

2 files changed

Lines changed: 56 additions & 6 deletions

File tree

Sources/XcodeGenKit/SourceGenerator.swift

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,13 @@ class SourceGenerator {
206206
let createIntermediateGroups = project.options.createIntermediateGroups
207207

208208
let parentPath = path.parent()
209+
210+
guard !isInsideSyncedFolder(path: path) else {
211+
return getFileReference(path: path, inPath: basePath, sourceTree: .sourceRoot)
212+
}
213+
209214
let fileReference = getFileReference(path: path, inPath: parentPath)
215+
210216
let parentGroup = getGroup(
211217
path: parentPath,
212218
mergingChildren: [fileReference],
@@ -283,6 +289,19 @@ class SourceGenerator {
283289
}
284290
}
285291

292+
/// Whether the given path falls inside a target source configured as a synced folder.
293+
/// Checks the project spec directly because configFiles are resolved before target sources
294+
/// populate `syncedGroupsByPath`.
295+
private func isInsideSyncedFolder(path: Path) -> Bool {
296+
let relativePath = (try? path.relativePath(from: basePath)) ?? path
297+
return project.targets.contains { target in
298+
target.sources.contains { source in
299+
let type = source.type ?? (project.options.defaultSourceDirectoryType ?? .group)
300+
return type == .syncedFolder && relativePath.string.hasPrefix(source.path + "/")
301+
}
302+
}
303+
}
304+
286305
/// returns a default build phase for a given path. This is based off the filename
287306
private func getDefaultBuildPhase(for path: Path, targetType: PBXProductType) -> BuildPhaseSpec? {
288307
if let buildPhase = getFileType(path: path)?.buildPhase {
@@ -356,7 +375,7 @@ class SourceGenerator {
356375
groupReference = addObject(group)
357376
groupsByPath[path] = groupReference
358377

359-
if isTopLevelGroup {
378+
if isTopLevelGroup && !isInsideSyncedFolder(path: path) {
360379
rootGroups.insert(groupReference)
361380
}
362381
}
@@ -402,6 +421,8 @@ class SourceGenerator {
402421
if child.isDirectory && !Xcode.isDirectoryFileWrapper(path: child) {
403422
findExceptions(in: child)
404423
}
424+
} else if child.isDirectory && !Xcode.isDirectoryFileWrapper(path: child) {
425+
findExceptions(in: child)
405426
} else {
406427
exceptions.insert(child)
407428
}

Tests/XcodeGenKitTests/SourceGeneratorTests.swift

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,7 @@ class SourceGeneratorTests: XCTestCase {
415415
try expect(exceptions.contains("Nested/b.swift")) == false
416416
}
417417

418-
$0.it("excludes entire subdirectory as single exception when no files in it are included") {
418+
$0.it("excludes individual files in subdirectory when no files in it are included") {
419419
let directories = """
420420
Sources:
421421
- a.swift
@@ -436,10 +436,11 @@ class SourceGeneratorTests: XCTestCase {
436436
let exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet)
437437
let exceptions = try unwrap(exceptionSet.membershipExceptions)
438438

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
439+
// Xcode does not recursively exclude directory contents from membershipExceptions,
440+
// so individual files must be listed instead of the directory name
441+
try expect(exceptions.contains("ExcludedDir")) == false
442+
try expect(exceptions.contains("ExcludedDir/x.swift")) == true
443+
try expect(exceptions.contains("ExcludedDir/y.swift")) == true
443444
try expect(exceptions.contains("a.swift")) == false
444445
}
445446

@@ -491,6 +492,34 @@ class SourceGeneratorTests: XCTestCase {
491492
try expect(appGroup === testsGroup) == true
492493
}
493494

495+
$0.it("does not create duplicate group for configFiles inside synced folder") {
496+
let directories = """
497+
Sources:
498+
- a.swift
499+
- Config:
500+
- config.xcconfig
501+
"""
502+
try createDirectories(directories)
503+
504+
let source = TargetSource(path: "Sources", type: .syncedFolder)
505+
let target = Target(name: "Target1", type: .application, platform: .iOS, sources: [source])
506+
let project = Project(
507+
basePath: directoryPath,
508+
name: "Test",
509+
targets: [target],
510+
configFiles: ["Debug": "Sources/Config/config.xcconfig"]
511+
)
512+
513+
let pbxProj = try project.generatePbxProj()
514+
let mainGroup = try pbxProj.getMainGroup()
515+
516+
let sourcesChildren = mainGroup.children.filter { $0.path == "Sources" || $0.name == "Sources" }
517+
try expect(sourcesChildren.count) == 1
518+
519+
let syncedFolders = mainGroup.children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
520+
try expect(syncedFolders.count) == 1
521+
}
522+
494523
$0.it("supports frameworks in sources") {
495524
let directories = """
496525
Sources:

0 commit comments

Comments
 (0)