Skip to content

Commit b754a33

Browse files
authored
feat: show more right (#1)
* Add leafFoldersOnly comparison method * Fix tests * Added info operation and reporting * Update README.md
1 parent 6674c5c commit b754a33

11 files changed

Lines changed: 712 additions & 364 deletions

.swift-format

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
{
2-
"indentation": {
3-
"spaces": 4
4-
},
5-
"rules": {
6-
"AlwaysUseLowerCamelCase": false
7-
}
2+
"indentation": {
3+
"spaces": 4,
4+
},
5+
"indentConditionalCompilationBlocks": false,
6+
"indentSwitchCaseLabels": true,
7+
"rules": {
8+
"AlwaysUseLowerCamelCase": false
9+
}
810
}

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ This is a convenience command equivalent to `files sync --no-deletions` with one
147147

148148
#### Options
149149

150+
- `--show-more-right` - Scan leaf directories on the right side for additional diff information
150151
- `--dry-run` - Preview changes without applying them
151152
- `--verbose`, `-v` - Show detailed output with all operations
152153
- `--format FORMAT` - Output format: `text` (default), `json`, `summary`
@@ -176,6 +177,7 @@ files sync <source-directory> <destination-directory> [options]
176177
- `--conflict-resolution STRATEGY` - For two-way sync: `newest` (default), `source`, `destination`, `skip`
177178
- `--recursive` / `--no-recursive` - Scan subdirectories recursively (default: recursive)
178179
- `--deletions` - Delete files in destination that don't exist in source (one-way sync only, default: false)
180+
- `--show-more-right` - Scan leaf directories on the right side for additional diff information (one-way sync without deletions only)
179181
- `--dry-run` - Preview changes without applying them
180182
- `--verbose`, `-v` - Show detailed output with all operations
181183
- `--format FORMAT` - Output format: `text` (default), `json`, `summary`

Sources/FilesKit/DirectoryDifference.swift

Lines changed: 229 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -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
94104
public 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
220411
private func scanDirectory(at path: String, recursive: Bool, ignore: Ignore)

0 commit comments

Comments
 (0)