From 3bbb4ffd338e2097d1ceb47ba2cea138d1f87e78 Mon Sep 17 00:00:00 2001 From: June Kim Date: Tue, 12 May 2026 21:08:23 -0700 Subject: [PATCH 1/4] Add failing test: allTrackedFiles includes .DS_Store and .orig files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #1032 — .DS_Store files in the cache cause spurious project regeneration. The source generator already excludes these files, but allTrackedFiles (used by CacheFile) does not. This commit adds a test that asserts .DS_Store and .orig files are excluded from allTrackedFiles. The test fails on the current implementation. --- Tests/ProjectSpecTests/ProjectSpecTests.swift | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/Tests/ProjectSpecTests/ProjectSpecTests.swift b/Tests/ProjectSpecTests/ProjectSpecTests.swift index c308df96..a6d40aed 100644 --- a/Tests/ProjectSpecTests/ProjectSpecTests.swift +++ b/Tests/ProjectSpecTests/ProjectSpecTests.swift @@ -591,6 +591,69 @@ class ProjectSpecTests: XCTestCase { } } + func testAllTrackedFilesExcludesIgnoredFiles() { + describe { + let directoryPath = Path(ProcessInfo.processInfo.globallyUniqueString) + + $0.before { + try? directoryPath.delete() + } + + $0.after { + try? directoryPath.delete() + } + + $0.it("excludes .DS_Store and .orig files from tracked files") { + // Create source directory with a mix of valid and ignored files + let sourcesDir = directoryPath + "Sources" + try sourcesDir.mkpath() + try (sourcesDir + "main.swift").write("") + try (sourcesDir + ".DS_Store").write("") + try (sourcesDir + "file.swift.orig").write("") + + let target = Target( + name: "App", + type: .application, + platform: .iOS, + sources: [TargetSource(path: "Sources")] + ) + let project = Project(basePath: directoryPath, name: "Test", targets: [target]) + let trackedFiles = project.allTrackedFiles + + let trackedFileStrings = trackedFiles.map { $0.string } + let hasDSStore = trackedFileStrings.contains { $0.contains(".DS_Store") } + let hasOrig = trackedFileStrings.contains { $0.hasSuffix(".orig") } + let hasSwift = trackedFileStrings.contains { $0.contains("main.swift") } + + try expect(hasDSStore).to.beFalse() + try expect(hasOrig).to.beFalse() + try expect(hasSwift).to.beTrue() + } + + $0.it("excludes .DS_Store from fileGroup tracked files") { + // Create fileGroup directory with .DS_Store + let fileGroupDir = directoryPath + "Resources" + try fileGroupDir.mkpath() + try (fileGroupDir + "image.png").write("") + try (fileGroupDir + ".DS_Store").write("") + + let project = Project( + basePath: directoryPath, + name: "Test", + fileGroups: ["Resources"] + ) + let trackedFiles = project.allTrackedFiles + + let trackedFileStrings = trackedFiles.map { $0.string } + let hasDSStore = trackedFileStrings.contains { $0.contains(".DS_Store") } + let hasImage = trackedFileStrings.contains { $0.contains("image.png") } + + try expect(hasDSStore).to.beFalse() + try expect(hasImage).to.beTrue() + } + } + } + func testJSONEncodable() { describe { $0.it("encodes to json") { From 4f7f242084065a86e63a182a342d2cae3610708a Mon Sep 17 00:00:00 2001 From: June Kim Date: Tue, 12 May 2026 21:09:30 -0700 Subject: [PATCH 2/4] Exclude .DS_Store and .orig files from cache tracking allTrackedFiles now filters out the same files that SourceGenerator already excludes from project generation. Previously, .DS_Store files appearing in source directories would invalidate the cache and trigger a full project regeneration even though nothing in the generated project would change. Resolves #1032 --- Sources/ProjectSpec/Project.swift | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Sources/ProjectSpec/Project.swift b/Sources/ProjectSpec/Project.swift index e872a27b..5c171f05 100644 --- a/Sources/ProjectSpec/Project.swift +++ b/Sources/ProjectSpec/Project.swift @@ -252,6 +252,21 @@ extension Project: PathContainer { extension Project { + /// Files excluded from cache tracking to match SourceGenerator's default exclusions. + /// Without this filter, changes to these files cause spurious project regeneration. + private static let defaultExcludedFileNames: Set = [".DS_Store"] + private static let defaultExcludedExtensions: Set = ["orig"] + + private static func isTrackedFile(_ path: Path) -> Bool { + if defaultExcludedFileNames.contains(path.lastComponent) { + return false + } + if let ext = path.extension, defaultExcludedExtensions.contains(ext) { + return false + } + return true + } + public var allTrackedFiles: [Path] { var files: [Path] = [] files.append(contentsOf: configFilePaths) @@ -270,7 +285,7 @@ extension Project { files.append(contentsOf: target.configFilePaths) for source in target.sources { let sourcePath = basePath + source.path - + let type = source.type ?? options.defaultSourceDirectoryType ?? .group if type.projectTracksChildren { let sourceChildren = (try? sourcePath.recursiveChildren()) ?? [] @@ -279,7 +294,7 @@ extension Project { files.append(sourcePath) } } - return files + return files.filter(Self.isTrackedFile) } } From e5d7b777e5a597f705ff013f17eaf4bb547f7635 Mon Sep 17 00:00:00 2001 From: June Kim Date: Tue, 12 May 2026 21:13:57 -0700 Subject: [PATCH 3/4] Use NSTemporaryDirectory for test temp files to avoid repo pollution --- Tests/ProjectSpecTests/ProjectSpecTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ProjectSpecTests/ProjectSpecTests.swift b/Tests/ProjectSpecTests/ProjectSpecTests.swift index a6d40aed..43914c30 100644 --- a/Tests/ProjectSpecTests/ProjectSpecTests.swift +++ b/Tests/ProjectSpecTests/ProjectSpecTests.swift @@ -593,7 +593,7 @@ class ProjectSpecTests: XCTestCase { func testAllTrackedFilesExcludesIgnoredFiles() { describe { - let directoryPath = Path(ProcessInfo.processInfo.globallyUniqueString) + let directoryPath = Path(components: [NSTemporaryDirectory(), ProcessInfo.processInfo.globallyUniqueString]) $0.before { try? directoryPath.delete() From 347cdbed4ea817b9f962acdd3e7040e7a7162131 Mon Sep 17 00:00:00 2001 From: Yonas Kolb Date: Tue, 13 Oct 2026 17:36:42 +1100 Subject: [PATCH 4/4] Share default excluded files between SourceGenerator and cache tracking Move the .DS_Store and .orig exclusions into FileType.isDefaultExcluded so SourceGenerator and Project.allTrackedFiles use one list. Only tracked child files are filtered, so explicit paths and directories are still tracked. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + Sources/ProjectSpec/FileType.swift | 20 ++++++ Sources/ProjectSpec/Project.swift | 26 +++----- Sources/XcodeGenKit/SourceGenerator.swift | 10 +-- Tests/ProjectSpecTests/ProjectSpecTests.swift | 63 ++++++++++++------- 5 files changed, 68 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45ff4ff8..9206e765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed - Fix `syncedFolder` source paths being relative to the spec directory instead of the project directory when they differ, which caused Xcode to treat the synced folder as empty #1636 @Ckitakishi - Fix nested target attributes (e.g. `attributes.SystemCapabilities`) being serialized as a stringified Swift `Dictionary` description instead of a proper nested plist dictionary, which also caused non-deterministic key ordering in generated `project.pbxproj` files across runs #1639 @imadaan @sergeyospanov +- Fix `.DS_Store` and `.orig` files causing projects to be regenerated when using `--use-cache` #1622 @kimjune01 ### Internal - Use a dedicated local package in the SPM fixture so generated fixtures don't depend on the checkout directory name, such as when running tests from a git worktree @yonaskolb diff --git a/Sources/ProjectSpec/FileType.swift b/Sources/ProjectSpec/FileType.swift index 5e5a6575..22ded342 100644 --- a/Sources/ProjectSpec/FileType.swift +++ b/Sources/ProjectSpec/FileType.swift @@ -7,6 +7,7 @@ import Foundation import JSONUtilities +import PathKit import enum XcodeProj.BuildPhase public struct FileType: Equatable { @@ -124,3 +125,22 @@ extension FileType { "xctestplan": FileType(buildPhase: BuildPhaseSpec.none), ] } + +extension FileType { + + /// File names that are never added to the project or tracked by the cache + static let defaultExcludedFiles: Set = [ + ".DS_Store", + ] + + /// File extensions that are never added to the project or tracked by the cache + static let defaultExcludedExtensions: Set = [ + "orig", + ] + + /// Checks whether the path is in any default excludes + public static func isDefaultExcluded(_ path: Path) -> Bool { + defaultExcludedFiles.contains(path.lastComponent) + || (path.extension.map(defaultExcludedExtensions.contains) ?? false) + } +} diff --git a/Sources/ProjectSpec/Project.swift b/Sources/ProjectSpec/Project.swift index 813985f9..06f3d398 100644 --- a/Sources/ProjectSpec/Project.swift +++ b/Sources/ProjectSpec/Project.swift @@ -271,28 +271,17 @@ extension Project: PathContainer { extension Project { - /// Files excluded from cache tracking to match SourceGenerator's default exclusions. - /// Without this filter, changes to these files cause spurious project regeneration. - private static let defaultExcludedFileNames: Set = [".DS_Store"] - private static let defaultExcludedExtensions: Set = ["orig"] - - private static func isTrackedFile(_ path: Path) -> Bool { - if defaultExcludedFileNames.contains(path.lastComponent) { - return false - } - if let ext = path.extension, defaultExcludedExtensions.contains(ext) { - return false + public var allTrackedFiles: [Path] { + // Skip files that SourceGenerator never adds to the project. Directories are kept, as they can still become groups + func trackedChildren(of path: Path) -> [Path] { + ((try? path.recursiveChildren()) ?? []).filter { $0.isDirectory || !FileType.isDefaultExcluded($0) } } - return true - } - public var allTrackedFiles: [Path] { var files: [Path] = [] files.append(contentsOf: configFilePaths) for fileGroup in fileGroups { let fileGroupPath = basePath + fileGroup - let fileGroupChildren = (try? fileGroupPath.recursiveChildren()) ?? [] - files.append(contentsOf: fileGroupChildren) + files.append(contentsOf: trackedChildren(of: fileGroupPath)) files.append(fileGroupPath) } @@ -307,13 +296,12 @@ extension Project { let type = source.type ?? options.defaultSourceDirectoryType ?? .group if type.projectTracksChildren { - let sourceChildren = (try? sourcePath.recursiveChildren()) ?? [] - files.append(contentsOf: sourceChildren) + files.append(contentsOf: trackedChildren(of: sourcePath)) } files.append(sourcePath) } } - return files.filter(Self.isTrackedFile) + return files } } diff --git a/Sources/XcodeGenKit/SourceGenerator.swift b/Sources/XcodeGenKit/SourceGenerator.swift index 10313f47..d07737ae 100644 --- a/Sources/XcodeGenKit/SourceGenerator.swift +++ b/Sources/XcodeGenKit/SourceGenerator.swift @@ -23,13 +23,6 @@ class SourceGenerator { private let project: Project let pbxProj: PBXProj - private var defaultExcludedFiles = [ - ".DS_Store", - ] - private let defaultExcludedExtensions = [ - "orig", - ] - private(set) var knownRegions: Set = [] /// The effective base path for resolving group and file paths in the generated project. @@ -471,8 +464,7 @@ class SourceGenerator { /// 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 !FileType.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/ProjectSpecTests/ProjectSpecTests.swift b/Tests/ProjectSpecTests/ProjectSpecTests.swift index 43914c30..771a3fd2 100644 --- a/Tests/ProjectSpecTests/ProjectSpecTests.swift +++ b/Tests/ProjectSpecTests/ProjectSpecTests.swift @@ -595,21 +595,22 @@ class ProjectSpecTests: XCTestCase { describe { let directoryPath = Path(components: [NSTemporaryDirectory(), ProcessInfo.processInfo.globallyUniqueString]) - $0.before { - try? directoryPath.delete() - } - $0.after { try? directoryPath.delete() } - $0.it("excludes .DS_Store and .orig files from tracked files") { - // Create source directory with a mix of valid and ignored files + $0.it("excludes .DS_Store and .orig files from tracked source files") { let sourcesDir = directoryPath + "Sources" - try sourcesDir.mkpath() + let featureDir = sourcesDir + "Feature" + let assetsDir = sourcesDir + "Assets.xcassets" + try featureDir.mkpath() + try assetsDir.mkpath() try (sourcesDir + "main.swift").write("") try (sourcesDir + ".DS_Store").write("") try (sourcesDir + "file.swift.orig").write("") + try (featureDir + "Feature.swift").write("") + try (featureDir + ".DS_Store").write("") + try (assetsDir + ".DS_Store").write("") let target = Target( name: "App", @@ -618,38 +619,52 @@ class ProjectSpecTests: XCTestCase { sources: [TargetSource(path: "Sources")] ) let project = Project(basePath: directoryPath, name: "Test", targets: [target]) - let trackedFiles = project.allTrackedFiles - let trackedFileStrings = trackedFiles.map { $0.string } - let hasDSStore = trackedFileStrings.contains { $0.contains(".DS_Store") } - let hasOrig = trackedFileStrings.contains { $0.hasSuffix(".orig") } - let hasSwift = trackedFileStrings.contains { $0.contains("main.swift") } - - try expect(hasDSStore).to.beFalse() - try expect(hasOrig).to.beFalse() - try expect(hasSwift).to.beTrue() + try expect(Set(project.allTrackedFiles)) == [ + sourcesDir, + sourcesDir + "main.swift", + featureDir, + featureDir + "Feature.swift", + assetsDir, + ] } - $0.it("excludes .DS_Store from fileGroup tracked files") { - // Create fileGroup directory with .DS_Store + $0.it("excludes .DS_Store and .orig files from tracked fileGroup files") { let fileGroupDir = directoryPath + "Resources" try fileGroupDir.mkpath() try (fileGroupDir + "image.png").write("") try (fileGroupDir + ".DS_Store").write("") + try (fileGroupDir + "image.png.orig").write("") let project = Project( basePath: directoryPath, name: "Test", fileGroups: ["Resources"] ) - let trackedFiles = project.allTrackedFiles - let trackedFileStrings = trackedFiles.map { $0.string } - let hasDSStore = trackedFileStrings.contains { $0.contains(".DS_Store") } - let hasImage = trackedFileStrings.contains { $0.contains("image.png") } + try expect(Set(project.allTrackedFiles)) == [ + fileGroupDir, + fileGroupDir + "image.png", + ] + } + } + } - try expect(hasDSStore).to.beFalse() - try expect(hasImage).to.beTrue() + func testDefaultExcludedFiles() { + describe { + $0.it("matches default excluded file names and extensions") { + try expect(FileType.isDefaultExcluded(".DS_Store")) == true + try expect(FileType.isDefaultExcluded("Sources/.DS_Store")) == true + try expect(FileType.isDefaultExcluded("Sources/file.swift.orig")) == true + } + + $0.it("doesn't match similar names") { + try expect(FileType.isDefaultExcluded("Sources/file.swift")) == false + try expect(FileType.isDefaultExcluded("Sources/file.original")) == false + try expect(FileType.isDefaultExcluded("Sources/orig")) == false + try expect(FileType.isDefaultExcluded("Sources/.orig")) == false + try expect(FileType.isDefaultExcluded("Sources/file.ORIG")) == false + try expect(FileType.isDefaultExcluded("Sources/DS_Store")) == false } } }