Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Sources/XcodeGenCLI/Commands/GenerateCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down
61 changes: 61 additions & 0 deletions Sources/XcodeGenKit/ProjectDiff.swift
Original file line number Diff line number Diff line change
@@ -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<String> {
var paths = Set<String>()
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)!
}
}