Skip to content

Instantly share code, notes, and snippets.

@dimohamdy
Created July 29, 2025 20:08
Show Gist options
  • Save dimohamdy/2c873fa79e4ca1b4a35c6194683e815f to your computer and use it in GitHub Desktop.
Save dimohamdy/2c873fa79e4ca1b4a35c6194683e815f to your computer and use it in GitHub Desktop.
XcodeBuildTimer.swift
#!/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