Skip to content

Commit aae8777

Browse files
kimjune01yonaskolbclaude
authored
Exclude .DS_Store and .orig files from cache tracking (#1622)
* Add failing test: allTrackedFiles includes .DS_Store and .orig files 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. * 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 * Use NSTemporaryDirectory for test temp files to avoid repo pollution * 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 <noreply@anthropic.com> --------- Co-authored-by: Yonas Kolb <yonaskolb@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2b10be3 commit aae8777

5 files changed

Lines changed: 108 additions & 14 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
### Fixed
1414
- 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
1515
- 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
16+
- Fix `.DS_Store` and `.orig` files causing projects to be regenerated when using `--use-cache` #1622 @kimjune01
1617

1718
### Internal
1819
- 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

‎Sources/ProjectSpec/FileType.swift‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import Foundation
99
import JSONUtilities
10+
import PathKit
1011
import enum XcodeProj.BuildPhase
1112

1213
public struct FileType: Equatable {
@@ -124,3 +125,22 @@ extension FileType {
124125
"xctestplan": FileType(buildPhase: BuildPhaseSpec.none),
125126
]
126127
}
128+
129+
extension FileType {
130+
131+
/// File names that are never added to the project or tracked by the cache
132+
static let defaultExcludedFiles: Set<String> = [
133+
".DS_Store",
134+
]
135+
136+
/// File extensions that are never added to the project or tracked by the cache
137+
static let defaultExcludedExtensions: Set<String> = [
138+
"orig",
139+
]
140+
141+
/// Checks whether the path is in any default excludes
142+
public static func isDefaultExcluded(_ path: Path) -> Bool {
143+
defaultExcludedFiles.contains(path.lastComponent)
144+
|| (path.extension.map(defaultExcludedExtensions.contains) ?? false)
145+
}
146+
}

‎Sources/ProjectSpec/Project.swift‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -272,12 +272,16 @@ extension Project: PathContainer {
272272
extension Project {
273273

274274
public var allTrackedFiles: [Path] {
275+
// Skip files that SourceGenerator never adds to the project. Directories are kept, as they can still become groups
276+
func trackedChildren(of path: Path) -> [Path] {
277+
((try? path.recursiveChildren()) ?? []).filter { $0.isDirectory || !FileType.isDefaultExcluded($0) }
278+
}
279+
275280
var files: [Path] = []
276281
files.append(contentsOf: configFilePaths)
277282
for fileGroup in fileGroups {
278283
let fileGroupPath = basePath + fileGroup
279-
let fileGroupChildren = (try? fileGroupPath.recursiveChildren()) ?? []
280-
files.append(contentsOf: fileGroupChildren)
284+
files.append(contentsOf: trackedChildren(of: fileGroupPath))
281285
files.append(fileGroupPath)
282286
}
283287

@@ -289,11 +293,10 @@ extension Project {
289293
files.append(contentsOf: target.configFilePaths)
290294
for source in target.sources {
291295
let sourcePath = basePath + source.path
292-
296+
293297
let type = source.type ?? options.defaultSourceDirectoryType ?? .group
294298
if type.projectTracksChildren {
295-
let sourceChildren = (try? sourcePath.recursiveChildren()) ?? []
296-
files.append(contentsOf: sourceChildren)
299+
files.append(contentsOf: trackedChildren(of: sourcePath))
297300
}
298301
files.append(sourcePath)
299302
}

‎Sources/XcodeGenKit/SourceGenerator.swift‎

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,6 @@ class SourceGenerator {
2323
private let project: Project
2424
let pbxProj: PBXProj
2525

26-
private var defaultExcludedFiles = [
27-
".DS_Store",
28-
]
29-
private let defaultExcludedExtensions = [
30-
"orig",
31-
]
32-
3326
private(set) var knownRegions: Set<String> = []
3427

3528
/// The effective base path for resolving group and file paths in the generated project.
@@ -471,8 +464,7 @@ class SourceGenerator {
471464

472465
/// Checks whether the path is not in any default or TargetSource excludes
473466
func isIncludedPath(_ path: Path, excludePaths: Set<Path>, includePaths: SortedArray<Path>?) -> Bool {
474-
return !defaultExcludedFiles.contains(where: { path.lastComponent == $0 })
475-
&& !(path.extension.map(defaultExcludedExtensions.contains) ?? false)
467+
return !FileType.isDefaultExcluded(path)
476468
&& !excludePaths.contains(path)
477469
// 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.
478470
&& (includePaths.flatMap { _isIncludedPathSorted(path, sortedPaths: $0) } ?? true)

‎Tests/ProjectSpecTests/ProjectSpecTests.swift‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,84 @@ class ProjectSpecTests: XCTestCase {
591591
}
592592
}
593593

594+
func testAllTrackedFilesExcludesIgnoredFiles() {
595+
describe {
596+
let directoryPath = Path(components: [NSTemporaryDirectory(), ProcessInfo.processInfo.globallyUniqueString])
597+
598+
$0.after {
599+
try? directoryPath.delete()
600+
}
601+
602+
$0.it("excludes .DS_Store and .orig files from tracked source files") {
603+
let sourcesDir = directoryPath + "Sources"
604+
let featureDir = sourcesDir + "Feature"
605+
let assetsDir = sourcesDir + "Assets.xcassets"
606+
try featureDir.mkpath()
607+
try assetsDir.mkpath()
608+
try (sourcesDir + "main.swift").write("")
609+
try (sourcesDir + ".DS_Store").write("")
610+
try (sourcesDir + "file.swift.orig").write("")
611+
try (featureDir + "Feature.swift").write("")
612+
try (featureDir + ".DS_Store").write("")
613+
try (assetsDir + ".DS_Store").write("")
614+
615+
let target = Target(
616+
name: "App",
617+
type: .application,
618+
platform: .iOS,
619+
sources: [TargetSource(path: "Sources")]
620+
)
621+
let project = Project(basePath: directoryPath, name: "Test", targets: [target])
622+
623+
try expect(Set(project.allTrackedFiles)) == [
624+
sourcesDir,
625+
sourcesDir + "main.swift",
626+
featureDir,
627+
featureDir + "Feature.swift",
628+
assetsDir,
629+
]
630+
}
631+
632+
$0.it("excludes .DS_Store and .orig files from tracked fileGroup files") {
633+
let fileGroupDir = directoryPath + "Resources"
634+
try fileGroupDir.mkpath()
635+
try (fileGroupDir + "image.png").write("")
636+
try (fileGroupDir + ".DS_Store").write("")
637+
try (fileGroupDir + "image.png.orig").write("")
638+
639+
let project = Project(
640+
basePath: directoryPath,
641+
name: "Test",
642+
fileGroups: ["Resources"]
643+
)
644+
645+
try expect(Set(project.allTrackedFiles)) == [
646+
fileGroupDir,
647+
fileGroupDir + "image.png",
648+
]
649+
}
650+
}
651+
}
652+
653+
func testDefaultExcludedFiles() {
654+
describe {
655+
$0.it("matches default excluded file names and extensions") {
656+
try expect(FileType.isDefaultExcluded(".DS_Store")) == true
657+
try expect(FileType.isDefaultExcluded("Sources/.DS_Store")) == true
658+
try expect(FileType.isDefaultExcluded("Sources/file.swift.orig")) == true
659+
}
660+
661+
$0.it("doesn't match similar names") {
662+
try expect(FileType.isDefaultExcluded("Sources/file.swift")) == false
663+
try expect(FileType.isDefaultExcluded("Sources/file.original")) == false
664+
try expect(FileType.isDefaultExcluded("Sources/orig")) == false
665+
try expect(FileType.isDefaultExcluded("Sources/.orig")) == false
666+
try expect(FileType.isDefaultExcluded("Sources/file.ORIG")) == false
667+
try expect(FileType.isDefaultExcluded("Sources/DS_Store")) == false
668+
}
669+
}
670+
}
671+
594672
func testJSONEncodable() {
595673
describe {
596674
$0.it("encodes to json") {

0 commit comments

Comments
 (0)