From b978ed4808e51bc2bbb3143825ff5e218f1ba9aa Mon Sep 17 00:00:00 2001 From: samuraisatoshi Date: Sat, 28 Mar 2026 21:59:36 -0300 Subject: [PATCH 1/2] feat(generate): add --dry-run flag to diff project without writing Adds a new --dry-run flag to the generate command that generates the Xcode project in memory and prints a JSON diff of what would change compared to the existing project on disk, without writing any files. New type ProjectDiff captures added/removed/modified file keys in the pbxproj and serialises them as JSON. Co-Authored-By: Claude Sonnet 4.6 --- .../Commands/GenerateCommand.swift | 16 +++++ Sources/XcodeGenKit/ProjectDiff.swift | 61 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 Sources/XcodeGenKit/ProjectDiff.swift 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)! + } +} From dc1d64c6212c23c3152c1d49990b9fa29698d483 Mon Sep 17 00:00:00 2001 From: samuraisatoshi Date: Sat, 28 Mar 2026 22:00:44 -0300 Subject: [PATCH 2/2] feat(validate): add validate command with structured JSON output Adds a new `xcodegen validate` command that validates the project spec without generating a project. Output is always a JSON object with three keys: - valid (bool): whether the spec passed all checks - errors (array): each with stage and message fields - warnings (array): same shape This separates validation from generation and enables CI pipelines to get machine-readable validation results. Supports comma-separated --spec paths and all existing ProjectCommand flags (--no-env, --project-root). Exit code is 1 when the spec has errors, 0 when valid. Co-Authored-By: Claude Sonnet 4.6 --- .../Commands/ValidateCommand.swift | 99 +++++++++++++++++++ Sources/XcodeGenCLI/XcodeGenCLI.swift | 1 + 2 files changed, 100 insertions(+) create mode 100644 Sources/XcodeGenCLI/Commands/ValidateCommand.swift diff --git a/Sources/XcodeGenCLI/Commands/ValidateCommand.swift b/Sources/XcodeGenCLI/Commands/ValidateCommand.swift new file mode 100644 index 00000000..ae0fd458 --- /dev/null +++ b/Sources/XcodeGenCLI/Commands/ValidateCommand.swift @@ -0,0 +1,99 @@ +import Foundation +import PathKit +import ProjectSpec +import SwiftCLI +import XcodeGenKit +import Version + +class ValidateCommand: ProjectCommand { + + init(version: Version) { + super.init(version: version, + name: "validate", + shortDescription: "Validate the project spec without generating a project") + } + + // Fully override execute() so that parsing errors are also captured as JSON + override func execute() throws { + var specPaths: [Path] = [] + if let spec = spec { + specPaths = spec.components(separatedBy: ",").map { Path($0).absolute() } + } else { + specPaths = [Path("project.yml").absolute()] + } + + var allErrors: [ValidationIssue] = [] + var allWarnings: [ValidationIssue] = [] + + for specPath in specPaths { + guard specPath.exists else { + allErrors.append(ValidationIssue(stage: "parsing", + message: "No project spec found at \(specPath)")) + continue + } + + let specLoader = SpecLoader(version: version) + let variables: [String: String] = disableEnvExpansion ? [:] : ProcessInfo.processInfo.environment + + let project: Project + do { + project = try specLoader.loadProject(path: specPath, projectRoot: projectRoot, variables: variables) + } catch { + allErrors.append(ValidationIssue(stage: "parsing", message: error.localizedDescription)) + continue + } + + do { + try specLoader.validateProjectDictionaryWarnings() + } catch let e as SpecValidationError { + allWarnings += e.errors.map { ValidationIssue(stage: "validation", message: $0.description) } + } catch { + allWarnings.append(ValidationIssue(stage: "validation", message: error.localizedDescription)) + } + + do { + try project.validateMinimumXcodeGenVersion(version) + try project.validate() + } catch let e as SpecValidationError { + allErrors += e.errors.map { ValidationIssue(stage: "validation", message: $0.description) } + } catch { + allErrors.append(ValidationIssue(stage: "validation", message: error.localizedDescription)) + } + } + + let result = ValidationResult(valid: allErrors.isEmpty, errors: allErrors, warnings: allWarnings) + stdout.print(try result.jsonString()) + + if !result.valid { + throw ValidationFailed() + } + } + + // Not called — execute() is fully overridden above + override func execute(specLoader: SpecLoader, projectSpecPath: Path, project: Project) throws {} +} + +// MARK: - JSON output types + +private struct ValidationIssue: Encodable { + let stage: String + let message: String +} + +private struct ValidationResult: Encodable { + let valid: Bool + let errors: [ValidationIssue] + let warnings: [ValidationIssue] + + func jsonString() throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(self) + return String(data: data, encoding: .utf8)! + } +} + +private struct ValidationFailed: ProcessError { + var message: String? { nil } + var exitStatus: Int32 { 1 } +} diff --git a/Sources/XcodeGenCLI/XcodeGenCLI.swift b/Sources/XcodeGenCLI/XcodeGenCLI.swift index 8d6a69c5..116d886a 100644 --- a/Sources/XcodeGenCLI/XcodeGenCLI.swift +++ b/Sources/XcodeGenCLI/XcodeGenCLI.swift @@ -17,6 +17,7 @@ public class XcodeGenCLI { generateCommand, CacheCommand(version: version), DumpCommand(version: version), + ValidateCommand(version: version), ] ) cli.parser.routeBehavior = .searchWithFallback(generateCommand)