diff --git a/Sources/XcodeGenCLI/Commands/GenerateCommand.swift b/Sources/XcodeGenCLI/Commands/GenerateCommand.swift index d0e4eabe..90352e37 100644 --- a/Sources/XcodeGenCLI/Commands/GenerateCommand.swift +++ b/Sources/XcodeGenCLI/Commands/GenerateCommand.swift @@ -20,6 +20,9 @@ class GenerateCommand: ProjectCommand { @Flag("--only-plists", description: "Generate only plist files") var onlyPlists: Bool + @Flag("--dry-run", description: "Generate project in memory and print a JSON diff of what would change without writing any files") + var dryRun: Bool + init(version: Version) { super.init(version: version, name: "generate", @@ -110,6 +113,19 @@ class GenerateCommand: ProjectCommand { throw GenerationError.generationError(error) } + // dry-run: diff and exit without writing + if dryRun { + let existingPbxprojPath = XcodeProj.pbxprojPath(projectPath) + let existingXcodeprojPath = projectExists ? projectPath : nil + let diff = ProjectDiff(from: xcodeProject, against: existingXcodeprojPath) + do { + stdout.print(try diff.jsonString()) + } catch { + throw GenerationError.writingError(error) + } + return + } + // write project info("⚙️ Writing project...") do { diff --git a/Sources/XcodeGenKit/ProjectDiff.swift b/Sources/XcodeGenKit/ProjectDiff.swift new file mode 100644 index 00000000..f688092e --- /dev/null +++ b/Sources/XcodeGenKit/ProjectDiff.swift @@ -0,0 +1,61 @@ +import Foundation +import XcodeProj +import PathKit + +/// Computes a structural diff between two XcodeProj objects. +/// Comparison is by name/path — UUIDs are ignored for stability. +public struct ProjectDiff: Encodable { + + public let changed: Bool + public let targetsAdded: [String] + public let targetsRemoved: [String] + public let filesAdded: [String] + public let filesRemoved: [String] + + public init(from newProject: XcodeProj, against existingPath: Path?) { + let newTargetNames = Set(newProject.pbxproj.nativeTargets.map { $0.name }) + let newFilePaths = ProjectDiff.sourcePaths(from: newProject) + + guard let existingPath = existingPath, existingPath.exists, + let existing = try? XcodeProj(path: existingPath) else { + // No existing project — everything is "added" + targetsAdded = newTargetNames.sorted() + targetsRemoved = [] + filesAdded = newFilePaths.sorted() + filesRemoved = [] + changed = !targetsAdded.isEmpty || !filesAdded.isEmpty + return + } + + let existingTargetNames = Set(existing.pbxproj.nativeTargets.map { $0.name }) + let existingFilePaths = ProjectDiff.sourcePaths(from: existing) + + targetsAdded = newTargetNames.subtracting(existingTargetNames).sorted() + targetsRemoved = existingTargetNames.subtracting(newTargetNames).sorted() + filesAdded = newFilePaths.subtracting(existingFilePaths).sorted() + filesRemoved = existingFilePaths.subtracting(newFilePaths).sorted() + changed = !targetsAdded.isEmpty || !targetsRemoved.isEmpty + || !filesAdded.isEmpty || !filesRemoved.isEmpty + } + + private static func sourcePaths(from project: XcodeProj) -> Set { + var paths = Set() + for target in project.pbxproj.nativeTargets { + let files = (try? target.sourceFiles()) ?? [] + for file in files { + if let path = file.path { + paths.insert(path) + } + } + } + return paths + } + + public func jsonString() throws -> String { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(self) + return String(data: data, encoding: .utf8)! + } +}