@@ -82,22 +82,73 @@ public enum DirectoryDifferenceError: Error, Sendable {
8282 case accessDenied( String )
8383}
8484
85+ /// Specifies what files from the right directory to include in the comparison result
86+ public enum IncludeOnlyInRight : Sendable {
87+ /// Include all files from the right directory (full comparison)
88+ case all
89+ /// Don't include any files only in right (most optimized)
90+ case none
91+ /// Include only files in right's leaf directories that match left's leaf directories
92+ case leafFoldersOnly
93+ }
94+
8595/// Compares two directories and returns their differences
8696/// - Parameters:
8797/// - leftPath: Path to the left directory
8898/// - rightPath: Path to the right directory
8999/// - recursive: Whether to compare subdirectories recursively (default: true)
90- /// - includeOnlyInRight: If true, includes files only in right directory in the result (default: true). Set to false for optimization when you don't need to know about right-only files.
100+ /// - includeOnlyInRight: Specifies what files from the right directory to include (default: .all)
91101/// - ignore: Optional ignore patterns to skip certain files (default: nil, will auto-load from .filesignore files)
92102/// - Returns: A `DirectoryDifference` containing the differences between the directories
93103/// - Throws: `DirectoryDifferenceError` if directories are invalid or inaccessible
94104public func directoryDifference(
95105 left leftPath: String ,
96106 right rightPath: String ,
97107 recursive: Bool = true ,
98- includeOnlyInRight: Bool = true ,
108+ includeOnlyInRight: IncludeOnlyInRight = . all ,
99109 ignore: Ignore ? = nil
100110) async throws -> DirectoryDifference {
111+ // Validate directories
112+ try validateDirectories ( leftPath: leftPath, rightPath: rightPath)
113+
114+ // Load ignore patterns
115+ let patterns =
116+ if let ignore { ignore } else {
117+ await Ignore . load ( leftPath: leftPath, rightPath: rightPath)
118+ }
119+
120+ // Scan left directory
121+ let filesLeft = try await scanDirectory (
122+ at: leftPath, recursive: recursive, ignore: patterns)
123+
124+ // Choose the appropriate comparison strategy based on includeOnlyInRight mode
125+ return switch includeOnlyInRight {
126+ case . all:
127+ try await fullComparison (
128+ leftPath: leftPath,
129+ rightPath: rightPath,
130+ filesLeft: filesLeft,
131+ recursive: recursive,
132+ ignore: patterns
133+ )
134+ case . none:
135+ try await optimizedComparison (
136+ leftPath: leftPath,
137+ rightPath: rightPath,
138+ filesLeft: filesLeft
139+ )
140+ case . leafFoldersOnly:
141+ try await leafFoldersComparison (
142+ leftPath: leftPath,
143+ rightPath: rightPath,
144+ filesLeft: filesLeft,
145+ ignore: patterns
146+ )
147+ }
148+ }
149+
150+ /// Validates that both paths are valid directories
151+ private func validateDirectories( leftPath: String , rightPath: String ) throws {
101152 let fileManager = FileManager . default
102153
103154 var isDirectory : ObjCBool = false
@@ -112,43 +163,24 @@ public func directoryDifference(
112163 else {
113164 throw DirectoryDifferenceError . invalidDirectory ( rightPath)
114165 }
166+ }
115167
116- // Load ignore patterns if not provided
117- let patterns : Ignore
118- if let ignore = ignore {
119- patterns = ignore
120- } else {
121- patterns = await Ignore . load ( leftPath: leftPath, rightPath: rightPath)
122- }
123-
124- let filesLeft = try await scanDirectory (
125- at: leftPath, recursive: recursive, ignore: patterns)
126-
127- if !includeOnlyInRight {
128- // Optimized path: only check if left files exist in right
129- let ( common, modified) = try await checkLeftFilesInRight (
130- leftFiles: filesLeft,
131- leftPath: leftPath,
132- rightPath: rightPath
133- )
134-
135- return DirectoryDifference (
136- onlyInLeft: filesLeft. subtracting ( common) . subtracting ( modified) ,
137- onlyInRight: [ ] ,
138- common: common,
139- modified: modified
140- )
141- }
142-
143- // Full scan: compare both directories
168+ /// Full comparison mode: scans both directories completely
169+ @concurrent
170+ private func fullComparison(
171+ leftPath: String ,
172+ rightPath: String ,
173+ filesLeft: Set < String > ,
174+ recursive: Bool ,
175+ ignore: Ignore
176+ ) async throws -> DirectoryDifference {
144177 let filesRight = try await scanDirectory (
145- at: rightPath, recursive: recursive, ignore: patterns )
178+ at: rightPath, recursive: recursive, ignore: ignore )
146179
147180 let onlyInLeft = filesLeft. subtracting ( filesRight)
148181 let onlyInRight = filesRight. subtracting ( filesLeft)
149182 let common = filesLeft. intersection ( filesRight)
150183
151- // Check for modified files in common set
152184 let modified = try await findModifiedFiles (
153185 common: common,
154186 leftPath: leftPath,
@@ -163,6 +195,69 @@ public func directoryDifference(
163195 )
164196}
165197
198+ /// Optimized comparison mode: only checks if left files exist in right
199+ @concurrent
200+ private func optimizedComparison(
201+ leftPath: String ,
202+ rightPath: String ,
203+ filesLeft: Set < String >
204+ ) async throws -> DirectoryDifference {
205+ let ( common, modified) = try await checkLeftFilesInRight (
206+ leftFiles: filesLeft,
207+ leftPath: leftPath,
208+ rightPath: rightPath
209+ )
210+
211+ return DirectoryDifference (
212+ onlyInLeft: filesLeft. subtracting ( common) . subtracting ( modified) ,
213+ onlyInRight: [ ] ,
214+ common: common,
215+ modified: modified
216+ )
217+ }
218+
219+ /// Leaf folders comparison mode: scans left's leaf directories on the right side
220+ @concurrent
221+ private func leafFoldersComparison(
222+ leftPath: String ,
223+ rightPath: String ,
224+ filesLeft: Set < String > ,
225+ ignore: Ignore
226+ ) async throws -> DirectoryDifference {
227+ // First, do the optimized check for all left files
228+ let ( common, modified) = try await checkLeftFilesInRight (
229+ leftFiles: filesLeft,
230+ leftPath: leftPath,
231+ rightPath: rightPath
232+ )
233+
234+ // Derive leaf directories from the files we already scanned
235+ let leftLeafDirs = findLeafDirectoriesFromFiles ( filesLeft)
236+ let filesInRightLeaves = try await scanLeafDirectories (
237+ at: rightPath,
238+ leafDirs: leftLeafDirs,
239+ ignore: ignore
240+ )
241+
242+ // Calculate what's only in right (within left's leaf directories)
243+ let onlyInRightLeaves = filesInRightLeaves. subtracting ( filesLeft)
244+
245+ // For files in both, check if they're modified (but only within left's leaf dirs)
246+ let commonInLeaves = filesInRightLeaves. intersection ( filesLeft)
247+ let modifiedInLeaves = try await findModifiedFiles (
248+ common: commonInLeaves,
249+ leftPath: leftPath,
250+ rightPath: rightPath
251+ )
252+
253+ return DirectoryDifference (
254+ onlyInLeft: filesLeft. subtracting ( common) . subtracting ( modified) ,
255+ onlyInRight: onlyInRightLeaves,
256+ common: common. union ( commonInLeaves. subtracting ( modifiedInLeaves) ) ,
257+ modified: modified. union ( modifiedInLeaves)
258+ )
259+ }
260+
166261/// Checks which files from left exist in right and which are modified
167262/// This is an optimization that avoids scanning the entire right directory
168263@concurrent
@@ -196,12 +291,12 @@ private func checkLeftFilesInRight(
196291
197292 for try await (file, status) in group {
198293 switch status {
199- case . same:
200- common. insert ( file)
201- case . modified:
202- modified. insert ( file)
203- case . onlyInLeft:
204- break // Will be in onlyInLeft by subtraction
294+ case . same:
295+ common. insert ( file)
296+ case . modified:
297+ modified. insert ( file)
298+ case . onlyInLeft:
299+ break // Will be in onlyInLeft by subtraction
205300 }
206301 }
207302
@@ -215,6 +310,102 @@ private enum FileStatus {
215310 case onlyInLeft
216311}
217312
313+ /// Derives leaf directories from a set of file paths
314+ /// A leaf directory is one that contains files but has no subdirectories
315+ private func findLeafDirectoriesFromFiles( _ files: Set < String > ) -> Set < String > {
316+ var allDirectories = Set < String > ( )
317+ var directoriesWithSubdirs = Set < String > ( )
318+
319+ // Extract all directories from file paths
320+ for filePath in files {
321+ let components = ( filePath as NSString ) . pathComponents
322+
323+ // Build directory path incrementally
324+ for i in 0 ..< ( components. count - 1 ) { // Exclude the file name
325+ let dirPath = components [ 0 ... i] . joined ( separator: " / " )
326+ if !dirPath. isEmpty && dirPath != " . " {
327+ allDirectories. insert ( dirPath)
328+
329+ // Mark parent directories as having subdirectories
330+ if i > 0 {
331+ let parentPath = components [ 0 ..< i] . joined ( separator: " / " )
332+ if !parentPath. isEmpty && parentPath != " . " {
333+ directoriesWithSubdirs. insert ( parentPath)
334+ }
335+ }
336+ }
337+ }
338+ }
339+
340+ // Leaf directories are those without subdirectories
341+ return allDirectories. subtracting ( directoriesWithSubdirs)
342+ }
343+
344+ /// Scans only files within specified leaf directories
345+ @concurrent
346+ private func scanLeafDirectories(
347+ at path: String ,
348+ leafDirs: Set < String > ,
349+ ignore: Ignore
350+ ) async throws -> Set < String > {
351+ try await Task . detached {
352+ let fileManager = FileManager . default
353+ let baseURL = URL ( fileURLWithPath: path)
354+
355+ var files = Set < String > ( )
356+
357+ // Standardize base path
358+ let standardizedBase = baseURL. standardizedFileURL
359+ var standardizedBasePath = standardizedBase. path ( percentEncoded: false )
360+ if standardizedBasePath. hasSuffix ( " / " ) {
361+ standardizedBasePath = String ( standardizedBasePath. dropLast ( ) )
362+ }
363+
364+ // Scan each leaf directory
365+ for leafDir in leafDirs {
366+ let leafURL = baseURL. appendingPathComponent ( leafDir)
367+
368+ guard
369+ let enumerator = fileManager. enumerator (
370+ at: leafURL,
371+ includingPropertiesForKeys: [ . isDirectoryKey] ,
372+ options: [ . skipsSubdirectoryDescendants]
373+ )
374+ else {
375+ continue
376+ }
377+
378+ for case let fileURL as URL in enumerator. allObjects {
379+ let resourceValues = try fileURL. resourceValues ( forKeys: [ . isDirectoryKey] )
380+ let isDirectory = resourceValues. isDirectory == true
381+
382+ if !isDirectory {
383+ let standardizedFile = fileURL. standardizedFileURL
384+ var filePath = standardizedFile. path ( percentEncoded: false )
385+
386+ if filePath. hasSuffix ( " / " ) {
387+ filePath = String ( filePath. dropLast ( ) )
388+ }
389+
390+ // Get relative path from base
391+ let relativePath : String
392+ if filePath. hasPrefix ( standardizedBasePath + " / " ) {
393+ relativePath = String ( filePath. dropFirst ( standardizedBasePath. count + 1 ) )
394+ } else {
395+ relativePath = fileURL. lastPathComponent
396+ }
397+
398+ if !ignore. shouldIgnore ( relativePath, isDirectory: false ) {
399+ files. insert ( relativePath)
400+ }
401+ }
402+ }
403+ }
404+
405+ return files
406+ } . value
407+ }
408+
218409/// Scans a directory and returns relative paths of all files
219410@concurrent
220411private func scanDirectory( at path: String , recursive: Bool , ignore: Ignore )
0 commit comments