Created
July 29, 2025 20:08
-
-
Save dimohamdy/2c873fa79e4ca1b4a35c6194683e815f to your computer and use it in GitHub Desktop.
XcodeBuildTimer.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env swift | |
// | |
// XcodeBuildTimer.swift | |
// Xcode Build Timer & Data Extractor | |
// Usage: swift XcodeBuildTimer.swift [scheme] [project_name] [--dry-run] [--today-only] | |
// | |
import Foundation | |
// MARK: - Colors | |
enum Color { | |
static let red = "\u{001B}[0;31m" | |
static let green = "\u{001B}[0;32m" | |
static let blue = "\u{001B}[0;34m" | |
static let yellow = "\u{001B}[1;33m" | |
static let cyan = "\u{001B}[0;36m" | |
static let reset = "\u{001B}[0m" | |
} | |
// MARK: - Build Data Models | |
struct BuildEntry { | |
let date: Date | |
let duration: Int | |
let status: BuildStatus | |
let timestamp: TimeInterval | |
} | |
enum BuildStatus: String { | |
case success = "S" | |
case failed = "E" | |
case warning = "W" | |
case unknown = "U" | |
var displayName: String { | |
switch self { | |
case .success: return "SUCCESS" | |
case .failed: return "FAILED" | |
case .warning: return "WARNING" | |
case .unknown: return "UNKNOWN" | |
} | |
} | |
} | |
struct DailyStats { | |
var totalTime: Int = 0 | |
var count: Int = 0 | |
var shortest: Int = Int.max | |
var longest: Int = 0 | |
var success: Int = 0 | |
var failed: Int = 0 | |
var warnings: Int = 0 | |
} | |
// MARK: - Configuration | |
struct Config { | |
let scheme: String? | |
let projectName: String? | |
let dryRun: Bool | |
let todayOnly: Bool | |
let showHelp: Bool | |
init(arguments: [String]) { | |
var scheme: String? | |
var projectName: String? | |
var dryRun = false | |
var todayOnly = false | |
var showHelp = false | |
for (index, arg) in arguments.enumerated() { | |
switch arg { | |
case "--dry-run": | |
dryRun = true | |
case "--today-only": | |
todayOnly = true | |
case "--help", "-h": | |
showHelp = true | |
default: | |
if index == 1 && !arg.hasPrefix("-") { | |
scheme = arg | |
} else if index == 2 && !arg.hasPrefix("-") { | |
projectName = arg | |
} | |
} | |
} | |
self.scheme = scheme | |
self.projectName = projectName | |
self.dryRun = dryRun | |
self.todayOnly = todayOnly | |
self.showHelp = showHelp | |
} | |
} | |
// MARK: - XcodeBuildTimer | |
class XcodeBuildTimer { | |
private let config: Config | |
private let derivedDataPath: String | |
private let logFile = "build_times.log" | |
private let dateFormatter = DateFormatter() | |
init(config: Config) { | |
self.config = config | |
self.derivedDataPath = NSHomeDirectory() + "/Library/Developer/Xcode/DerivedData" | |
self.dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" | |
} | |
func run() { | |
// Show help if requested | |
if config.showHelp { | |
showHelp() | |
return | |
} | |
let projectName = getProjectName() | |
let scheme = config.scheme ?? projectName | |
// If no arguments provided, show daily totals by default | |
if config.scheme == nil && config.projectName == nil { | |
extractHistoricalData(projectName: projectName) | |
return | |
} | |
// Execute build | |
executeBuild(scheme: scheme, projectName: projectName) | |
} | |
// MARK: - Help | |
private func showHelp() { | |
print(""" | |
\(Color.blue)========================================\(Color.reset) | |
\(Color.blue) Xcode Build Timer & Data Extractor\(Color.reset) | |
\(Color.blue)========================================\(Color.reset) | |
\(Color.cyan)DESCRIPTION:\(Color.reset) | |
Times Xcode builds and tracks daily build statistics with detailed | |
analysis including shortest/longest build times and success rates. | |
\(Color.cyan)USAGE:\(Color.reset) | |
\(Color.green)XcodeBuildTimer.swift\(Color.reset) [scheme] [project_name] [options] | |
\(Color.cyan)ARGUMENTS:\(Color.reset) | |
\(Color.yellow)scheme\(Color.reset) Xcode scheme to build (optional) | |
\(Color.yellow)project_name\(Color.reset) Project name without .xcodeproj extension (optional) | |
\(Color.cyan)OPTIONS:\(Color.reset) | |
\(Color.yellow)--dry-run\(Color.reset) Simulate build without actually running xcodebuild | |
\(Color.yellow)--today-only\(Color.reset) Show only today's build statistics | |
\(Color.yellow)--help, -h\(Color.reset) Show this help message | |
\(Color.cyan)EXAMPLES:\(Color.reset) | |
\(Color.green)# Show daily build statistics (default)\(Color.reset) | |
./XcodeBuildTimer.swift | |
\(Color.green)# Run build with timing for specific scheme and project\(Color.reset) | |
./XcodeBuildTimer.swift MyApp MyProject | |
\(Color.green)# Dry run - simulate build without executing\(Color.reset) | |
./XcodeBuildTimer.swift MyApp MyProject --dry-run | |
\(Color.green)# Show only today's statistics\(Color.reset) | |
./XcodeBuildTimer.swift --today-only | |
\(Color.green)# Interactive project selection\(Color.reset) | |
./XcodeBuildTimer.swift MyApp | |
\(Color.cyan)OUTPUT:\(Color.reset) | |
Daily statistics include: | |
• Total build time per day | |
• Number of builds per day | |
• Shortest and longest build times | |
• Success rate percentage | |
• Failed and warning counts | |
Build logs are saved to: \(Color.yellow)build_times.log\(Color.reset) | |
\(Color.cyan)REQUIREMENTS:\(Color.reset) | |
• macOS with Xcode installed | |
• Swift runtime available | |
• Access to ~/Library/Developer/Xcode/DerivedData | |
\(Color.blue)========================================\(Color.reset) | |
""") | |
} | |
// MARK: - Project Selection | |
private func getProjectName() -> String { | |
if let projectName = config.projectName { | |
return projectName | |
} | |
print("\(Color.blue)Available Xcode Projects\(Color.reset)") | |
let projects = getAvailableProjects() | |
if projects.isEmpty { | |
print("\(Color.red)❌ No projects found in DerivedData\(Color.reset)") | |
print("\(Color.cyan)Enter project name (without .xcodeproj):\(Color.reset)") | |
guard let input = readLine(), !input.isEmpty else { | |
print("\(Color.red)❌ Project name cannot be empty\(Color.reset)") | |
exit(1) | |
} | |
return input | |
} | |
for (index, project) in projects.enumerated() { | |
print("\(Color.cyan)\(index + 1).\(Color.reset) \(project)") | |
} | |
print("\(Color.cyan)Enter project number (1-\(projects.count)):\(Color.reset)") | |
guard let input = readLine(), | |
let number = Int(input), | |
number >= 1 && number <= projects.count else { | |
print("\(Color.red)❌ Invalid selection\(Color.reset)") | |
exit(1) | |
} | |
return projects[number - 1] | |
} | |
private func getAvailableProjects() -> [String] { | |
let fileManager = FileManager.default | |
guard let contents = try? fileManager.contentsOfDirectory(atPath: derivedDataPath) else { | |
return [] | |
} | |
return contents | |
.filter { $0.contains("-") } | |
.compactMap { projectFolder in | |
// Extract clean project name by removing hash suffix | |
let components = projectFolder.components(separatedBy: "-") | |
guard components.count >= 2 else { return nil } | |
return components.dropLast().joined(separator: "-") | |
} | |
.sorted() | |
} | |
// MARK: - Historical Data Extraction | |
private func extractHistoricalData(projectName: String) { | |
print("\(Color.cyan)🔍 Extracting build data...\(Color.reset)") | |
let projectFolder = findProjectFolder(projectName: projectName) | |
guard !projectFolder.isEmpty else { | |
print("\(Color.red)❌ No DerivedData folder found for: \(projectName)\(Color.reset)") | |
return | |
} | |
let buildPlistPath = projectFolder + "/Logs/Build/LogStoreManifest.plist" | |
guard FileManager.default.fileExists(atPath: buildPlistPath) else { | |
print("\(Color.yellow)⚠️ No build logs found\(Color.reset)") | |
return | |
} | |
let builds = parseBuildData(from: buildPlistPath) | |
guard !builds.isEmpty else { | |
if config.todayOnly { | |
print("\n\(Color.yellow)⚠️ No builds found for today\(Color.reset)") | |
print("Try running without --today-only to see historical data") | |
} else { | |
print("\n\(Color.yellow)⚠️ No build history found\(Color.reset)") | |
print("Run some builds first to see statistics") | |
} | |
return | |
} | |
displayDailyStatistics(builds: builds) | |
} | |
private func findProjectFolder(projectName: String) -> String { | |
let fileManager = FileManager.default | |
guard let contents = try? fileManager.contentsOfDirectory(atPath: derivedDataPath) else { | |
return "" | |
} | |
for folder in contents { | |
if folder.contains(projectName) { | |
return derivedDataPath + "/" + folder | |
} | |
} | |
return "" | |
} | |
private func parseBuildData(from plistPath: String) -> [BuildEntry] { | |
guard let plistData = NSDictionary(contentsOfFile: plistPath), | |
let logs = plistData["logs"] as? [String: [String: Any]] else { | |
return [] | |
} | |
var builds: [BuildEntry] = [] | |
let calendar = Calendar.current | |
let today = calendar.startOfDay(for: Date()) | |
for (_, logData) in logs { | |
guard let startTime = logData["timeStartedRecording"] as? TimeInterval, | |
let stopTime = logData["timeStoppedRecording"] as? TimeInterval else { | |
continue | |
} | |
// Convert Mac absolute time to Unix timestamp | |
let unixTimestamp = startTime + 978307200 | |
let buildDate = Date(timeIntervalSince1970: unixTimestamp) | |
// Filter for today only if requested | |
if config.todayOnly { | |
let buildDay = calendar.startOfDay(for: buildDate) | |
if buildDay != today { | |
continue | |
} | |
} | |
let duration = Int(stopTime - startTime) | |
// Extract build status | |
let statusString = (logData["primaryObservable"] as? [String: Any])?["highLevelStatus"] as? String ?? "U" | |
let status = BuildStatus(rawValue: statusString) ?? .unknown | |
builds.append(BuildEntry( | |
date: buildDate, | |
duration: duration, | |
status: status, | |
timestamp: unixTimestamp | |
)) | |
} | |
return builds.sorted { $0.timestamp > $1.timestamp } | |
} | |
private func displayDailyStatistics(builds: [BuildEntry]) { | |
print("\n\(Color.blue)========================================\(Color.reset)") | |
print("\(Color.blue) Daily Build Statistics\(Color.reset)") | |
print("\(Color.blue)========================================\(Color.reset)") | |
let calendar = Calendar.current | |
var dailyStats: [String: DailyStats] = [:] | |
// Group builds by date | |
for build in builds { | |
let dateKey = calendar.dateInterval(of: .day, for: build.date)?.start ?? build.date | |
let dateString = DateFormatter.dateKey.string(from: dateKey) | |
if dailyStats[dateString] == nil { | |
dailyStats[dateString] = DailyStats() | |
} | |
dailyStats[dateString]!.totalTime += build.duration | |
dailyStats[dateString]!.count += 1 | |
dailyStats[dateString]!.shortest = min(dailyStats[dateString]!.shortest, build.duration) | |
dailyStats[dateString]!.longest = max(dailyStats[dateString]!.longest, build.duration) | |
switch build.status { | |
case .success: | |
dailyStats[dateString]!.success += 1 | |
case .failed: | |
dailyStats[dateString]!.failed += 1 | |
case .warning: | |
dailyStats[dateString]!.warnings += 1 | |
case .unknown: | |
break | |
} | |
} | |
// Display statistics sorted by date (newest first) | |
let sortedDates = dailyStats.keys.sorted().reversed() | |
for dateString in sortedDates { | |
guard let stats = dailyStats[dateString] else { continue } | |
let formattedDate = DateFormatter.display.string(from: DateFormatter.dateKey.date(from: dateString)!) | |
let totalTimeStr = formatDuration(stats.totalTime) | |
let shortestTimeStr = formatDuration(stats.shortest == Int.max ? 0 : stats.shortest) | |
let longestTimeStr = formatDuration(stats.longest) | |
let successRate = stats.count > 0 ? Int(Double(stats.success) / Double(stats.count) * 100) : 0 | |
print("\(Color.cyan)📅 \(formattedDate):\(Color.reset)") | |
print(" Total: \(totalTimeStr) | Builds: \(stats.count) | Success: \(successRate)%") | |
print(" Shortest: \(shortestTimeStr) | Longest: \(longestTimeStr)") | |
if stats.failed > 0 || stats.warnings > 0 { | |
print(" \(Color.red)Failed: \(stats.failed)\(Color.reset) | \(Color.yellow)Warnings: \(stats.warnings)\(Color.reset)") | |
} | |
print() | |
} | |
print("\(Color.blue)========================================\(Color.reset)") | |
} | |
// MARK: - Build Execution | |
private func executeBuild(scheme: String, projectName: String) { | |
let startTime = Date() | |
let buildId = Int(startTime.timeIntervalSince1970) | |
print("\(Color.blue)========================================\(Color.reset)") | |
print("\(Color.blue) Xcode Build Timer\(Color.reset)") | |
print("\(Color.blue)========================================\(Color.reset)") | |
print("\(Color.cyan)Date:\(Color.reset) \(dateFormatter.string(from: startTime))") | |
print("\(Color.cyan)Scheme:\(Color.reset) \(scheme)") | |
print("\(Color.cyan)Project:\(Color.reset) \(projectName).xcodeproj") | |
if config.dryRun { | |
print("\(Color.yellow)Mode:\(Color.reset) DRY RUN") | |
} | |
print("\(Color.blue)========================================\(Color.reset)") | |
let result: String | |
let duration: Int | |
if config.dryRun { | |
print("\(Color.yellow)DRY RUN: Simulating build...\(Color.reset)") | |
Thread.sleep(forTimeInterval: 2) | |
result = "DRY_RUN" | |
duration = 2 | |
} else { | |
let buildResult = executeXcodeBuild(scheme: scheme, project: "\(projectName).xcodeproj") | |
result = buildResult.success ? "SUCCESS" : "FAILED" | |
duration = Int(Date().timeIntervalSince(startTime)) | |
if buildResult.success { | |
print("\(Color.green)✅ Build completed successfully!\(Color.reset)") | |
} else { | |
print("\(Color.red)❌ Build failed!\(Color.reset)") | |
} | |
} | |
let formattedDuration = formatDuration(duration) | |
print("\(Color.blue)========================================\(Color.reset)") | |
print("\(Color.blue) Build Summary\(Color.reset)") | |
print("\(Color.blue)========================================\(Color.reset)") | |
print("\(Color.cyan)Result:\(Color.reset) \(result)") | |
print("\(Color.cyan)Duration:\(Color.reset) \(formattedDuration)") | |
print("\(Color.cyan)End Time:\(Color.reset) \(dateFormatter.string(from: Date()))") | |
print("\(Color.blue)========================================\(Color.reset)") | |
// Log build result | |
logBuildResult(buildId: buildId, startTime: startTime, scheme: scheme, project: "\(projectName).xcodeproj", result: result, duration: duration, formattedDuration: formattedDuration) | |
// Show daily totals | |
print("\n\(Color.cyan)Daily Build Totals:\(Color.reset)") | |
extractHistoricalData(projectName: projectName) | |
} | |
private func executeXcodeBuild(scheme: String, project: String) -> (success: Bool, output: String) { | |
let process = Process() | |
process.executableURL = URL(fileURLWithPath: "/usr/bin/xcodebuild") | |
process.arguments = ["-scheme", scheme, "-project", project, "clean", "build", "-quiet"] | |
let pipe = Pipe() | |
process.standardOutput = pipe | |
process.standardError = pipe | |
do { | |
try process.run() | |
process.waitUntilExit() | |
let data = pipe.fileHandleForReading.readDataToEndOfFile() | |
let output = String(data: data, encoding: .utf8) ?? "" | |
return (process.terminationStatus == 0, output) | |
} catch { | |
return (false, "Failed to execute xcodebuild: \(error)") | |
} | |
} | |
private func logBuildResult(buildId: Int, startTime: Date, scheme: String, project: String, result: String, duration: Int, formattedDuration: String) { | |
let logEntry = """ | |
{ | |
"build_id": "\(buildId)", | |
"timestamp": "\(dateFormatter.string(from: startTime))", | |
"scheme": "\(scheme)", | |
"project": "\(project)", | |
"result": "\(result)", | |
"duration_seconds": \(duration), | |
"duration_formatted": "\(formattedDuration)" | |
} | |
""" | |
guard let data = (logEntry + "\n").data(using: .utf8) else { return } | |
if FileManager.default.fileExists(atPath: logFile) { | |
if let fileHandle = FileHandle(forWritingAtPath: logFile) { | |
fileHandle.seekToEndOfFile() | |
fileHandle.write(data) | |
fileHandle.closeFile() | |
} | |
} else { | |
FileManager.default.createFile(atPath: logFile, contents: data, attributes: nil) | |
} | |
} | |
// MARK: - Utilities | |
private func formatDuration(_ seconds: Int) -> String { | |
let hours = seconds / 3600 | |
let minutes = (seconds % 3600) / 60 | |
let secs = seconds % 60 | |
if hours > 0 { | |
return "\(hours)h \(minutes)m \(secs)s" | |
} else if minutes > 0 { | |
return "\(minutes)m \(secs)s" | |
} else { | |
return "\(secs)s" | |
} | |
} | |
} | |
// MARK: - Extensions | |
extension DateFormatter { | |
static let dateKey: DateFormatter = { | |
let formatter = DateFormatter() | |
formatter.dateFormat = "yyyy-MM-dd" | |
return formatter | |
}() | |
static let display: DateFormatter = { | |
let formatter = DateFormatter() | |
formatter.dateFormat = "dd-MM-yyyy" | |
return formatter | |
}() | |
} | |
// MARK: - Main | |
let config = Config(arguments: CommandLine.arguments) | |
let timer = XcodeBuildTimer(config: config) | |
timer.run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment